@terreno/rtk 0.13.3 → 0.14.1
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/authSlice.d.ts +4 -4
- package/dist/authSlice.d.ts.map +1 -1
- package/dist/authSlice.js +7 -7
- package/dist/authSlice.js.map +1 -1
- package/dist/authSlice.test.js +2 -10
- package/dist/authSlice.test.js.map +1 -1
- package/dist/authSliceNative.test.js +11 -3
- package/dist/authSliceNative.test.js.map +1 -1
- package/dist/betterAuthSlice.d.ts.map +1 -1
- package/dist/betterAuthSlice.js +3 -2
- package/dist/betterAuthSlice.js.map +1 -1
- package/dist/betterAuthSlice.test.js +1 -25
- package/dist/betterAuthSlice.test.js.map +1 -1
- package/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +11 -3
- package/dist/constants.js.map +1 -1
- package/dist/constants.test.js +85 -2
- package/dist/constants.test.js.map +1 -1
- package/dist/emptyApi.d.ts +3 -1
- package/dist/emptyApi.d.ts.map +1 -1
- package/dist/emptyApi.js +7 -2
- package/dist/emptyApi.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/isolated/constants.isolated.js +7 -1
- package/dist/isolated/constants.isolated.js.map +1 -1
- package/dist/isolated/offlineMiddleware.isolated.d.ts +2 -0
- package/dist/isolated/offlineMiddleware.isolated.d.ts.map +1 -0
- package/dist/isolated/offlineMiddleware.isolated.js +516 -0
- package/dist/isolated/offlineMiddleware.isolated.js.map +1 -0
- package/dist/isolated/offlineMiddleware.native.isolated.d.ts +2 -0
- package/dist/isolated/offlineMiddleware.native.isolated.d.ts.map +1 -0
- package/dist/isolated/offlineMiddleware.native.isolated.js +70 -0
- package/dist/isolated/offlineMiddleware.native.isolated.js.map +1 -0
- package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts +2 -0
- package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts.map +1 -0
- package/dist/{useUpgradeCheck.mobile.test.js → isolated/useUpgradeCheck.mobile.isolated.js} +13 -5
- package/dist/isolated/useUpgradeCheck.mobile.isolated.js.map +1 -0
- package/dist/isolated/useUpgradeCheck.web.isolated.d.ts +2 -0
- package/dist/isolated/useUpgradeCheck.web.isolated.d.ts.map +1 -0
- package/dist/{useUpgradeCheck.test.js → isolated/useUpgradeCheck.web.isolated.js} +37 -7
- package/dist/isolated/useUpgradeCheck.web.isolated.js.map +1 -0
- package/dist/mongooseSlice.d.ts +3 -2
- package/dist/mongooseSlice.d.ts.map +1 -1
- package/dist/mongooseSlice.js +3 -4
- package/dist/mongooseSlice.js.map +1 -1
- package/dist/mongooseSlice.test.js +1 -1
- package/dist/mongooseSlice.test.js.map +1 -1
- package/dist/offlineGate.d.ts +8 -0
- package/dist/offlineGate.d.ts.map +1 -0
- package/dist/offlineGate.js +20 -0
- package/dist/offlineGate.js.map +1 -0
- package/dist/offlineGate.test.d.ts +2 -0
- package/dist/offlineGate.test.d.ts.map +1 -0
- package/dist/offlineGate.test.js +46 -0
- package/dist/offlineGate.test.js.map +1 -0
- package/dist/offlineMiddleware.d.ts +46 -0
- package/dist/offlineMiddleware.d.ts.map +1 -0
- package/dist/offlineMiddleware.js +589 -0
- package/dist/offlineMiddleware.js.map +1 -0
- package/dist/offlineMiddleware.test.d.ts +2 -0
- package/dist/offlineMiddleware.test.d.ts.map +1 -0
- package/dist/offlineMiddleware.test.js +145 -0
- package/dist/offlineMiddleware.test.js.map +1 -0
- package/dist/offlineSlice.d.ts +232 -0
- package/dist/offlineSlice.d.ts.map +1 -0
- package/dist/offlineSlice.js +65 -0
- package/dist/offlineSlice.js.map +1 -0
- package/dist/offlineSlice.test.d.ts +2 -0
- package/dist/offlineSlice.test.d.ts.map +1 -0
- package/dist/offlineSlice.test.js +153 -0
- package/dist/offlineSlice.test.js.map +1 -0
- package/dist/realtime.d.ts +123 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +299 -0
- package/dist/realtime.js.map +1 -0
- package/dist/realtime.test.d.ts +2 -0
- package/dist/realtime.test.d.ts.map +1 -0
- package/dist/realtime.test.js +339 -0
- package/dist/realtime.test.js.map +1 -0
- package/dist/socket.d.ts.map +1 -1
- package/dist/socket.js +29 -28
- package/dist/socket.js.map +1 -1
- package/dist/sync.d.ts +35 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +205 -0
- package/dist/sync.js.map +1 -0
- package/dist/tagGenerator.d.ts +20 -1
- package/dist/tagGenerator.d.ts.map +1 -1
- package/dist/tagGenerator.js +4 -12
- package/dist/tagGenerator.js.map +1 -1
- package/dist/useFeatureFlags.d.ts +4 -2
- package/dist/useFeatureFlags.d.ts.map +1 -1
- package/dist/useFeatureFlags.js +3 -5
- package/dist/useFeatureFlags.js.map +1 -1
- package/dist/useOfflineStatus.d.ts +33 -0
- package/dist/useOfflineStatus.d.ts.map +1 -0
- package/dist/useOfflineStatus.js +43 -0
- package/dist/useOfflineStatus.js.map +1 -0
- package/dist/useOfflineStatus.test.d.ts +2 -0
- package/dist/useOfflineStatus.test.d.ts.map +1 -0
- package/dist/useOfflineStatus.test.js +119 -0
- package/dist/useOfflineStatus.test.js.map +1 -0
- package/dist/useRealtimeDebug.d.ts +6 -0
- package/dist/useRealtimeDebug.d.ts.map +1 -0
- package/dist/useRealtimeDebug.js +41 -0
- package/dist/useRealtimeDebug.js.map +1 -0
- package/dist/useServerStatus.d.ts +29 -0
- package/dist/useServerStatus.d.ts.map +1 -0
- package/dist/useServerStatus.js +89 -0
- package/dist/useServerStatus.js.map +1 -0
- package/dist/useUpgradeCheck.d.ts +7 -3
- package/dist/useUpgradeCheck.d.ts.map +1 -1
- package/dist/useUpgradeCheck.js +27 -13
- package/dist/useUpgradeCheck.js.map +1 -1
- package/package.json +2 -2
- package/src/authSlice.test.ts +2 -11
- package/src/authSlice.ts +9 -9
- package/src/authSliceNative.test.ts +11 -4
- package/src/betterAuthSlice.test.ts +1 -25
- package/src/betterAuthSlice.ts +3 -2
- package/src/constants.test.ts +99 -1
- package/src/constants.ts +14 -3
- package/src/emptyApi.ts +11 -3
- package/src/index.ts +8 -0
- package/src/isolated/constants.isolated.ts +7 -3
- package/src/isolated/offlineMiddleware.isolated.ts +696 -0
- package/src/isolated/offlineMiddleware.native.isolated.ts +80 -0
- package/src/{useUpgradeCheck.mobile.test.ts → isolated/useUpgradeCheck.mobile.isolated.ts} +13 -4
- package/src/{useUpgradeCheck.test.ts → isolated/useUpgradeCheck.web.isolated.ts} +48 -6
- package/src/mongooseSlice.test.ts +1 -1
- package/src/mongooseSlice.ts +4 -5
- package/src/offlineGate.test.ts +55 -0
- package/src/offlineGate.ts +25 -0
- package/src/offlineMiddleware.test.ts +187 -0
- package/src/offlineMiddleware.ts +716 -0
- package/src/offlineSlice.test.ts +204 -0
- package/src/offlineSlice.ts +130 -0
- package/src/realtime.test.ts +404 -0
- package/src/realtime.ts +408 -0
- package/src/socket.ts +33 -32
- package/src/sync.ts +310 -0
- package/src/tagGenerator.ts +30 -18
- package/src/useFeatureFlags.ts +6 -6
- package/src/useOfflineStatus.test.ts +157 -0
- package/src/useOfflineStatus.ts +82 -0
- package/src/useRealtimeDebug.ts +51 -0
- package/src/useServerStatus.ts +122 -0
- package/src/useUpgradeCheck.ts +35 -13
- package/dist/isolated/useUpgradeCheck.isolated.d.ts +0 -2
- package/dist/isolated/useUpgradeCheck.isolated.d.ts.map +0 -1
- package/dist/isolated/useUpgradeCheck.isolated.js +0 -153
- package/dist/isolated/useUpgradeCheck.isolated.js.map +0 -1
- package/dist/useUpgradeCheck.mobile.test.d.ts +0 -2
- package/dist/useUpgradeCheck.mobile.test.d.ts.map +0 -1
- package/dist/useUpgradeCheck.mobile.test.js.map +0 -1
- package/dist/useUpgradeCheck.test.d.ts +0 -2
- package/dist/useUpgradeCheck.test.d.ts.map +0 -1
- package/dist/useUpgradeCheck.test.js.map +0 -1
- package/src/isolated/useUpgradeCheck.isolated.ts +0 -201
package/src/realtime.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import {DateTime} from "luxon";
|
|
2
|
+
import type {Socket} from "socket.io-client";
|
|
3
|
+
|
|
4
|
+
import {isWebsocketsDebugEnabled, logSocket} from "./constants";
|
|
5
|
+
|
|
6
|
+
interface DocumentData {
|
|
7
|
+
_id?: string;
|
|
8
|
+
id?: string;
|
|
9
|
+
updated?: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ListCacheDraft {
|
|
14
|
+
data?: DocumentData[];
|
|
15
|
+
total?: number;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CacheLifecycleApi<TDraft> {
|
|
20
|
+
updateCachedData: (updateRecipe: (draft: TDraft) => void) => void;
|
|
21
|
+
cacheDataLoaded: Promise<unknown>;
|
|
22
|
+
cacheEntryRemoved: Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A real-time sync event received from the server via WebSocket.
|
|
27
|
+
* Must be kept in sync with the backend RealtimeEvent in @terreno/api.
|
|
28
|
+
*/
|
|
29
|
+
export interface RealtimeEvent {
|
|
30
|
+
/** Mongoose model name (e.g. "Todo") */
|
|
31
|
+
model: string;
|
|
32
|
+
/** Route path used as tag type (e.g. "todos") */
|
|
33
|
+
collection: string;
|
|
34
|
+
/** The CRUD method that triggered this event */
|
|
35
|
+
method: "create" | "update" | "delete";
|
|
36
|
+
/** Document ID */
|
|
37
|
+
id: string;
|
|
38
|
+
/** Serialized document data (omitted for hard deletes) */
|
|
39
|
+
data?: DocumentData;
|
|
40
|
+
/** Fields that were updated (for update events) */
|
|
41
|
+
updatedFields?: string[];
|
|
42
|
+
/** Epoch milliseconds when the event was generated */
|
|
43
|
+
timestamp: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Socket management
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
let _socket: Socket | null = null;
|
|
51
|
+
const _socketWaiters: ((socket: Socket) => void)[] = [];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Provide the Socket.io client instance used by `realtimeDocument` and `realtimeList`.
|
|
55
|
+
*
|
|
56
|
+
* Call this once after your socket connects (e.g. inside `useSocketConnection`'s `onConnect`
|
|
57
|
+
* callback, or in a `useEffect` that watches the socket ref).
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```typescript
|
|
61
|
+
* const { socket } = useSocketConnection({ ... });
|
|
62
|
+
*
|
|
63
|
+
* useEffect(() => {
|
|
64
|
+
* setRealtimeSocket(socket);
|
|
65
|
+
* return () => setRealtimeSocket(null);
|
|
66
|
+
* }, [socket]);
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export const setRealtimeSocket = (socket: Socket | null): void => {
|
|
70
|
+
_socket = socket;
|
|
71
|
+
if (socket) {
|
|
72
|
+
while (_socketWaiters.length > 0) {
|
|
73
|
+
const waiter = _socketWaiters.shift();
|
|
74
|
+
waiter?.(socket);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Get the current socket instance (may be null). */
|
|
80
|
+
export const getRealtimeSocket = (): Socket | null => _socket;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns a promise that resolves as soon as a socket is available.
|
|
84
|
+
* Accepts an optional abort promise — if it resolves first, returns null
|
|
85
|
+
* so the caller can bail out (e.g. when the cache entry is removed).
|
|
86
|
+
*/
|
|
87
|
+
const waitForSocket = (abort?: Promise<void>): Promise<Socket | null> => {
|
|
88
|
+
if (_socket) {
|
|
89
|
+
return Promise.resolve(_socket);
|
|
90
|
+
}
|
|
91
|
+
const socketPromise = new Promise<Socket>((resolve) => {
|
|
92
|
+
_socketWaiters.push(resolve);
|
|
93
|
+
});
|
|
94
|
+
if (!abort) {
|
|
95
|
+
return socketPromise;
|
|
96
|
+
}
|
|
97
|
+
return Promise.race([socketPromise, abort.then(() => null)]);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Helpers
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** Keys that represent pagination/sorting, not document field filters. */
|
|
105
|
+
const PAGINATION_KEYS = new Set(["limit", "page", "sort", "skip", "offset", "cursor"]);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Strip pagination params from a query argument to get just the filter.
|
|
109
|
+
* Returns undefined if no filter fields remain.
|
|
110
|
+
*/
|
|
111
|
+
const extractQueryFilter = (
|
|
112
|
+
arg: Record<string, unknown> | null | undefined
|
|
113
|
+
): Record<string, unknown> | undefined => {
|
|
114
|
+
if (!arg || typeof arg !== "object") {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
const filter: Record<string, unknown> = {};
|
|
118
|
+
for (const [key, value] of Object.entries(arg)) {
|
|
119
|
+
if (!PAGINATION_KEYS.has(key)) {
|
|
120
|
+
filter[key] = value;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return Object.keys(filter).length > 0 ? filter : undefined;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Normalize websocket document payloads to match REST API shape (`id` from `_id`). */
|
|
127
|
+
const normalizeRealtimeData = (data: DocumentData): DocumentData => {
|
|
128
|
+
if (data._id != null && data.id == null) {
|
|
129
|
+
return {...data, id: data._id};
|
|
130
|
+
}
|
|
131
|
+
return data;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Deterministic hash for a query object — used as the room ID. */
|
|
135
|
+
const hashQuery = (collection: string, query: Record<string, unknown>): string => {
|
|
136
|
+
const sortedKeys = Object.keys(query).sort();
|
|
137
|
+
const normalized: Record<string, unknown> = {};
|
|
138
|
+
for (const key of sortedKeys) {
|
|
139
|
+
normalized[key] = query[key];
|
|
140
|
+
}
|
|
141
|
+
return `${collection}:${JSON.stringify(normalized)}`;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// onCacheEntryAdded factories
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
interface RealtimeDocumentOptions {
|
|
149
|
+
/**
|
|
150
|
+
* Extract the document ID from the RTK Query argument.
|
|
151
|
+
* Defaults to `arg` if string, or `arg.id ?? arg._id`.
|
|
152
|
+
*/
|
|
153
|
+
getId?: (arg: unknown) => string | undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Factory that returns an `onCacheEntryAdded` callback for real-time
|
|
158
|
+
* updates on a **single document**.
|
|
159
|
+
*
|
|
160
|
+
* Subscribes to `document:{collection}:{id}` room on the server. When a sync
|
|
161
|
+
* event arrives for this document, patches the RTK Query cache in-place.
|
|
162
|
+
*
|
|
163
|
+
* @param collection - The collection tag (e.g. "todos")
|
|
164
|
+
* @param options - Optional configuration
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```typescript
|
|
168
|
+
* const api = generatedApi.enhanceEndpoints({
|
|
169
|
+
* endpoints: {
|
|
170
|
+
* getTodosIdRead: {
|
|
171
|
+
* onCacheEntryAdded: realtimeDocument("todos"),
|
|
172
|
+
* },
|
|
173
|
+
* },
|
|
174
|
+
* });
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
export const realtimeDocument = (collection: string, options?: RealtimeDocumentOptions) => {
|
|
178
|
+
const getId =
|
|
179
|
+
options?.getId ??
|
|
180
|
+
((arg: unknown): string | undefined => {
|
|
181
|
+
if (typeof arg === "string") {
|
|
182
|
+
return arg;
|
|
183
|
+
}
|
|
184
|
+
const obj = arg as Record<string, unknown> | null | undefined;
|
|
185
|
+
return (obj?.id as string | undefined) ?? (obj?._id as string | undefined);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return async (arg: unknown, api: CacheLifecycleApi<DocumentData>): Promise<void> => {
|
|
189
|
+
const {updateCachedData, cacheDataLoaded, cacheEntryRemoved} = api;
|
|
190
|
+
|
|
191
|
+
const id = getId(arg);
|
|
192
|
+
if (!id) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await cacheDataLoaded;
|
|
198
|
+
} catch {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const socket = await waitForSocket(cacheEntryRemoved);
|
|
203
|
+
if (!socket) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
socket.emit("subscribe:document", {collection, id});
|
|
208
|
+
|
|
209
|
+
const handleSync = (event: RealtimeEvent): void => {
|
|
210
|
+
if (isWebsocketsDebugEnabled()) {
|
|
211
|
+
logSocket(true, `realtimeDocument(${collection}/${id}) sync: ${JSON.stringify(event)}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (event.collection !== collection || event.id !== id) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (event.method === "update" && event.data) {
|
|
219
|
+
const data = normalizeRealtimeData(event.data);
|
|
220
|
+
updateCachedData((draft: DocumentData) => {
|
|
221
|
+
Object.assign(draft, data);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// For deletes, the cache entry will be invalidated by tag invalidation
|
|
226
|
+
// or the consuming component can handle the deleted state.
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
socket.on("sync", handleSync);
|
|
230
|
+
|
|
231
|
+
await cacheEntryRemoved;
|
|
232
|
+
socket.off("sync", handleSync);
|
|
233
|
+
socket.emit("unsubscribe:document", {collection, id});
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
interface RealtimeListOptions {
|
|
238
|
+
/**
|
|
239
|
+
* Extract the query filter from the RTK Query argument.
|
|
240
|
+
* Defaults to stripping pagination keys (limit, page, sort, skip, offset, cursor).
|
|
241
|
+
* Return undefined to subscribe to the model room instead of a query room.
|
|
242
|
+
*/
|
|
243
|
+
getQuery?: (arg: unknown) => Record<string, unknown> | undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface QuerySubscribedPayload {
|
|
247
|
+
clientQueryId?: string;
|
|
248
|
+
collection: string;
|
|
249
|
+
queryId: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Factory that returns an `onCacheEntryAdded` callback for real-time
|
|
254
|
+
* updates on a **list of documents**, optionally filtered by query.
|
|
255
|
+
*
|
|
256
|
+
* If the query argument contains filter fields (after stripping pagination params),
|
|
257
|
+
* subscribes to `query:{queryId}` so the server only sends matching events.
|
|
258
|
+
* Otherwise subscribes to `model:{collection}` for all events.
|
|
259
|
+
*
|
|
260
|
+
* Handles:
|
|
261
|
+
* - **create** → prepends new document to the list
|
|
262
|
+
* - **update** → patches existing document in-place, or adds it if newly matching
|
|
263
|
+
* - **delete** → removes document from the list
|
|
264
|
+
*
|
|
265
|
+
* @param collection - The collection tag (e.g. "todos")
|
|
266
|
+
* @param options - Optional configuration
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```typescript
|
|
270
|
+
* const api = generatedApi.enhanceEndpoints({
|
|
271
|
+
* endpoints: {
|
|
272
|
+
* getTodosList: {
|
|
273
|
+
* onCacheEntryAdded: realtimeList("todos"),
|
|
274
|
+
* },
|
|
275
|
+
* },
|
|
276
|
+
* });
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
279
|
+
export const realtimeList = (collection: string, options?: RealtimeListOptions) => {
|
|
280
|
+
const getQuery =
|
|
281
|
+
options?.getQuery ??
|
|
282
|
+
((arg: unknown) => extractQueryFilter(arg as Record<string, unknown> | null | undefined));
|
|
283
|
+
|
|
284
|
+
return async (arg: unknown, api: CacheLifecycleApi<ListCacheDraft>): Promise<void> => {
|
|
285
|
+
const {updateCachedData, cacheDataLoaded, cacheEntryRemoved} = api;
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
await cacheDataLoaded;
|
|
289
|
+
} catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const socket = await waitForSocket(cacheEntryRemoved);
|
|
294
|
+
if (!socket) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const query = getQuery(arg);
|
|
299
|
+
let queryId: string | undefined;
|
|
300
|
+
let canonicalQueryId: string | undefined;
|
|
301
|
+
let handleQuerySubscribed: ((payload: QuerySubscribedPayload) => void) | undefined;
|
|
302
|
+
|
|
303
|
+
if (query) {
|
|
304
|
+
queryId = hashQuery(collection, query);
|
|
305
|
+
handleQuerySubscribed = (payload: QuerySubscribedPayload): void => {
|
|
306
|
+
if (payload.collection !== collection) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (payload.clientQueryId && payload.clientQueryId !== queryId) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
canonicalQueryId = payload.queryId;
|
|
313
|
+
};
|
|
314
|
+
socket.on("query:subscribed", handleQuerySubscribed);
|
|
315
|
+
socket.emit("subscribe:query", {collection, query, queryId});
|
|
316
|
+
} else {
|
|
317
|
+
socket.emit("subscribe:model", collection);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const handleSync = (event: RealtimeEvent): void => {
|
|
321
|
+
if (isWebsocketsDebugEnabled()) {
|
|
322
|
+
logSocket(true, `realtimeList(${collection}) sync: ${JSON.stringify(event)}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (event.collection !== collection) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
switch (event.method) {
|
|
330
|
+
case "create": {
|
|
331
|
+
if (event.data) {
|
|
332
|
+
const data = normalizeRealtimeData(event.data);
|
|
333
|
+
updateCachedData((draft: ListCacheDraft) => {
|
|
334
|
+
if (draft?.data && Array.isArray(draft.data)) {
|
|
335
|
+
draft.data.unshift(data);
|
|
336
|
+
if (typeof draft.total === "number") {
|
|
337
|
+
draft.total += 1;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
case "update": {
|
|
346
|
+
if (event.data) {
|
|
347
|
+
const data = normalizeRealtimeData(event.data);
|
|
348
|
+
updateCachedData((draft: ListCacheDraft) => {
|
|
349
|
+
if (draft?.data && Array.isArray(draft.data)) {
|
|
350
|
+
const index = draft.data.findIndex(
|
|
351
|
+
(item: DocumentData) => item._id === event.id || item.id === event.id
|
|
352
|
+
);
|
|
353
|
+
if (index !== -1) {
|
|
354
|
+
// Stale event check
|
|
355
|
+
const cachedUpdated = draft.data[index].updated;
|
|
356
|
+
if (
|
|
357
|
+
cachedUpdated &&
|
|
358
|
+
data.updated &&
|
|
359
|
+
DateTime.fromISO(cachedUpdated) > DateTime.fromISO(data.updated)
|
|
360
|
+
) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
Object.assign(draft.data[index], data);
|
|
364
|
+
} else {
|
|
365
|
+
// Document newly matches query — add it to the list
|
|
366
|
+
draft.data.unshift(data);
|
|
367
|
+
if (typeof draft.total === "number") {
|
|
368
|
+
draft.total += 1;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case "delete": {
|
|
378
|
+
updateCachedData((draft: ListCacheDraft) => {
|
|
379
|
+
if (draft?.data && Array.isArray(draft.data)) {
|
|
380
|
+
const before = draft.data.length;
|
|
381
|
+
draft.data = draft.data.filter(
|
|
382
|
+
(item: DocumentData) => item._id !== event.id && item.id !== event.id
|
|
383
|
+
);
|
|
384
|
+
if (draft.data.length < before && typeof draft.total === "number") {
|
|
385
|
+
draft.total = Math.max(0, draft.total - 1);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
socket.on("sync", handleSync);
|
|
395
|
+
|
|
396
|
+
await cacheEntryRemoved;
|
|
397
|
+
socket.off("sync", handleSync);
|
|
398
|
+
if (handleQuerySubscribed) {
|
|
399
|
+
socket.off("query:subscribed", handleQuerySubscribed);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (queryId) {
|
|
403
|
+
socket.emit("unsubscribe:query", {queryId: canonicalQueryId ?? queryId});
|
|
404
|
+
} else {
|
|
405
|
+
socket.emit("unsubscribe:model", collection);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
};
|
package/src/socket.ts
CHANGED
|
@@ -37,6 +37,7 @@ export const useSocketConnection = ({
|
|
|
37
37
|
isSocketConnected: SocketConnection;
|
|
38
38
|
} => {
|
|
39
39
|
const toast = useToast();
|
|
40
|
+
const socketRef = useRef<Socket | null>(null);
|
|
40
41
|
const [socket, setSocket] = useState<Socket | null>(null);
|
|
41
42
|
const isConnectedRef = useRef<SocketConnection>(undefined);
|
|
42
43
|
const [isSocketConnected, setIsSocketConnected] = useState<SocketConnection>({
|
|
@@ -59,13 +60,17 @@ export const useSocketConnection = ({
|
|
|
59
60
|
reconnectionAttempts: 5,
|
|
60
61
|
reconnectionDelay: 1000,
|
|
61
62
|
reconnectionDelayMax: 5000,
|
|
62
|
-
|
|
63
|
+
// Start with polling so dev server restarts don't fail the initial websocket-only
|
|
64
|
+
// handshake; Socket.io upgrades to websocket once the connection is established.
|
|
65
|
+
transports: ["polling", "websocket"],
|
|
63
66
|
});
|
|
64
67
|
|
|
68
|
+
socketRef.current = socketIo;
|
|
65
69
|
setSocket(socketIo);
|
|
66
70
|
|
|
67
71
|
return (): void => {
|
|
68
72
|
socketIo.disconnect();
|
|
73
|
+
socketRef.current = null;
|
|
69
74
|
};
|
|
70
75
|
}, [baseUrl]);
|
|
71
76
|
|
|
@@ -97,17 +102,18 @@ export const useSocketConnection = ({
|
|
|
97
102
|
logAuth("[SocketConnection] Token received from getAuthToken.");
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
if (
|
|
105
|
+
if (socketRef.current) {
|
|
106
|
+
const socketInstance = socketRef.current;
|
|
101
107
|
// Enhanced logging for Option 1 (token status)
|
|
102
108
|
logAuth(
|
|
103
|
-
`[SocketConnection] Socket connecting ${token ? "with" : "without"} token. Current socket state: ${
|
|
109
|
+
`[SocketConnection] Socket connecting ${token ? "with" : "without"} token. Current socket state: ${socketInstance.connected ? "connected" : "disconnected"}`
|
|
104
110
|
);
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
socketInstance.auth = {token: `Bearer ${token}`};
|
|
112
|
+
socketInstance.connect();
|
|
107
113
|
} else {
|
|
108
|
-
|
|
114
|
+
logAuth("[SocketConnection] connectSocket skipped — socket not initialized yet.");
|
|
109
115
|
}
|
|
110
|
-
}, [
|
|
116
|
+
}, [getAuthToken]);
|
|
111
117
|
|
|
112
118
|
// Extracted logic for checking token expiration, refreshing token, and handling related UI
|
|
113
119
|
const checkAndRefreshTokenLogic = useCallback(
|
|
@@ -130,11 +136,12 @@ export const useSocketConnection = ({
|
|
|
130
136
|
);
|
|
131
137
|
await refreshAuthToken();
|
|
132
138
|
// Attempt to reconnect after token refresh
|
|
133
|
-
|
|
139
|
+
const socketInstance = socketRef.current;
|
|
140
|
+
if (shouldConnect && socketInstance && !socketInstance.connected) {
|
|
134
141
|
logAuth(
|
|
135
142
|
`[SocketConnection] Attempting to reconnect socket after token refresh due to ${context}.`
|
|
136
143
|
);
|
|
137
|
-
|
|
144
|
+
socketInstance.connect();
|
|
138
145
|
}
|
|
139
146
|
}
|
|
140
147
|
} catch (error) {
|
|
@@ -171,7 +178,7 @@ export const useSocketConnection = ({
|
|
|
171
178
|
}
|
|
172
179
|
}
|
|
173
180
|
},
|
|
174
|
-
[shouldConnect,
|
|
181
|
+
[shouldConnect, captureEvent, hideDisconnectedToast, toast, hideTokenErrorToast]
|
|
175
182
|
);
|
|
176
183
|
|
|
177
184
|
// Use Redux state for token refresh signal
|
|
@@ -200,8 +207,12 @@ export const useSocketConnection = ({
|
|
|
200
207
|
previousTokenRefreshTimestampRef.current = lastTokenRefreshTimestamp;
|
|
201
208
|
}, [lastTokenRefreshTimestamp, socket, shouldConnect, connectSocket, hideTokenErrorToast]);
|
|
202
209
|
|
|
203
|
-
// Connect/disconnect socket based on shouldConnect flag
|
|
210
|
+
// Connect/disconnect socket based on shouldConnect flag (waits until socket is initialized)
|
|
204
211
|
useEffect(() => {
|
|
212
|
+
if (!socket) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
205
216
|
if (shouldConnect) {
|
|
206
217
|
if (!isSocketConnected.isConnected) {
|
|
207
218
|
logAuth(
|
|
@@ -214,35 +225,17 @@ export const useSocketConnection = ({
|
|
|
214
225
|
);
|
|
215
226
|
}
|
|
216
227
|
} else {
|
|
228
|
+
logAuth(`[SocketConnection] Disconnecting socket because shouldConnect is false.`);
|
|
229
|
+
socket?.disconnect();
|
|
217
230
|
if (isSocketConnected.isConnected) {
|
|
218
|
-
logAuth(
|
|
219
|
-
`[SocketConnection] Attempting to disconnect socket because shouldConnect is false and socket is connected.`
|
|
220
|
-
);
|
|
221
|
-
socket?.disconnect();
|
|
222
231
|
setIsSocketConnected({
|
|
223
232
|
isConnected: false,
|
|
224
|
-
lastDisconnectedAt: null,
|
|
233
|
+
lastDisconnectedAt: null,
|
|
225
234
|
});
|
|
226
|
-
} else {
|
|
227
|
-
logAuth(
|
|
228
|
-
`[SocketConnection] Socket is already disconnected and shouldConnect is false. No action needed.`
|
|
229
|
-
);
|
|
230
235
|
}
|
|
231
236
|
}
|
|
232
237
|
}, [connectSocket, shouldConnect, isSocketConnected, socket]);
|
|
233
238
|
|
|
234
|
-
// Attempt to reconnect if token was refreshed and we are disconnected
|
|
235
|
-
useEffect(() => {
|
|
236
|
-
if (shouldConnect && !isSocketConnected.isConnected && socket) {
|
|
237
|
-
logAuth("[SocketConnection] Token refresh detected, attempting to reconnect socket.");
|
|
238
|
-
// We might want to ensure the socket isn't already in a connecting state here
|
|
239
|
-
// if socket.io-client provides such a state.
|
|
240
|
-
// Forcing a disconnect first can help if it's stuck in a bad state.
|
|
241
|
-
socket.disconnect();
|
|
242
|
-
void connectSocket();
|
|
243
|
-
}
|
|
244
|
-
}, [shouldConnect, isSocketConnected.isConnected, socket, connectSocket]);
|
|
245
|
-
|
|
246
239
|
// Show toast when disconnected
|
|
247
240
|
useEffect(() => {
|
|
248
241
|
if (!shouldConnect) {
|
|
@@ -350,6 +343,14 @@ export const useSocketConnection = ({
|
|
|
350
343
|
|
|
351
344
|
const handleConnectError = async (connectionError: Error): Promise<void> => {
|
|
352
345
|
const tokenInfo = await getFriendlyExpirationInfo();
|
|
346
|
+
const browserOffline = typeof navigator !== "undefined" && !navigator.onLine;
|
|
347
|
+
|
|
348
|
+
if (browserOffline) {
|
|
349
|
+
logAuth(
|
|
350
|
+
`[SocketConnection] Socket connection skipped while offline: ${connectionError.message}`
|
|
351
|
+
);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
353
354
|
|
|
354
355
|
console.error(
|
|
355
356
|
"[SocketConnection] Socket connection error:",
|