nexabase-console 2.0.6 → 2.0.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/app/NexaApp.d.ts +6 -1
- package/dist/app/NexaApp.js +42 -2
- package/dist/firestore/writes.js +16 -17
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -0
- package/dist/persistence/LocalStore.d.ts +1 -0
- package/dist/persistence/LocalStore.js +43 -0
- package/dist/transport/SSEClient.d.ts +6 -5
- package/dist/transport/SSEClient.js +19 -76
- package/dist/transport/WebSocketClient.d.ts +24 -0
- package/dist/transport/WebSocketClient.js +245 -0
- package/package.json +3 -2
package/dist/app/NexaApp.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AxiosInstance } from 'axios';
|
|
2
2
|
import { NexaConfig, DatabaseReference, StorageReference, UploadOptions, OfflineJob } from '../types/index';
|
|
3
3
|
import { HttpClient } from '../transport/HttpClient';
|
|
4
|
-
import { SSEClient } from '../transport/SSEClient';
|
|
4
|
+
import { SSEClient, WebSocketClient } from '../transport/SSEClient';
|
|
5
5
|
import { Auth } from '../auth/Auth';
|
|
6
6
|
import { User as AuthUser, AuthSession } from '../auth/authTypes';
|
|
7
7
|
import { IndexedDB } from '../persistence/IndexedDB';
|
|
@@ -18,6 +18,7 @@ export declare class NexaApp {
|
|
|
18
18
|
cacheStrategy: 'network-first' | 'cache-first';
|
|
19
19
|
httpClient: HttpClient;
|
|
20
20
|
client: AxiosInstance;
|
|
21
|
+
wsClient: WebSocketClient;
|
|
21
22
|
sseClient: SSEClient;
|
|
22
23
|
authService: Auth;
|
|
23
24
|
indexedDB: IndexedDB;
|
|
@@ -43,7 +44,9 @@ export declare class NexaApp {
|
|
|
43
44
|
_addOfflineJob(job: OfflineJob): Promise<void>;
|
|
44
45
|
_syncOfflineQueue(): Promise<void>;
|
|
45
46
|
_ensureFirestoreSSE(): void;
|
|
47
|
+
_ensureFirestoreWebSocket(): void;
|
|
46
48
|
_closeFirestoreSSEIfIdle(): void;
|
|
49
|
+
_closeFirestoreWebSocketIfIdle(): void;
|
|
47
50
|
_notifySnapshotCallbacks(path: string, actionType: string, payload: any): void;
|
|
48
51
|
signInWithEmailAndPassword(email: string, password: string): Promise<AuthSession>;
|
|
49
52
|
createUserWithEmailAndPassword(email: string, password: string, name?: string): Promise<AuthSession>;
|
|
@@ -61,4 +64,6 @@ export declare class NexaApp {
|
|
|
61
64
|
path: string;
|
|
62
65
|
}>;
|
|
63
66
|
getDownloadURL(path: string): Promise<string>;
|
|
67
|
+
isConnected(): boolean;
|
|
68
|
+
onConnectionStateChanged(callback: (isConnected: boolean) => void): () => void;
|
|
64
69
|
}
|
package/dist/app/NexaApp.js
CHANGED
|
@@ -31,6 +31,7 @@ class NexaApp {
|
|
|
31
31
|
// Transport
|
|
32
32
|
this.httpClient = new HttpClient_1.HttpClient(this.endpoint, () => (this.authService ? this.authService.getToken() : this.token), this.projectId, this.token);
|
|
33
33
|
this.client = this.httpClient.getAxiosInstance();
|
|
34
|
+
this.wsClient = new SSEClient_1.WebSocketClient(this.projectId, this.endpoint);
|
|
34
35
|
this.sseClient = new SSEClient_1.SSEClient(this.projectId, this.endpoint);
|
|
35
36
|
// Auth & Persistence
|
|
36
37
|
this.authService = new Auth_1.Auth(this.projectId, this.client, this.token);
|
|
@@ -77,6 +78,7 @@ class NexaApp {
|
|
|
77
78
|
try {
|
|
78
79
|
await this.indexedDB.init();
|
|
79
80
|
await this.localStore.loadAll();
|
|
81
|
+
await this.localStore.garbageCollect(1000); // 1000 docs limit
|
|
80
82
|
await this.mutationQueue.init();
|
|
81
83
|
if (typeof window !== 'undefined') {
|
|
82
84
|
window.addEventListener('online', () => this._syncOfflineQueue());
|
|
@@ -103,7 +105,7 @@ class NexaApp {
|
|
|
103
105
|
if (this._firestoreListening)
|
|
104
106
|
return;
|
|
105
107
|
this._firestoreListening = true;
|
|
106
|
-
const unsub1 = this.
|
|
108
|
+
const unsub1 = this.wsClient.addListener('data_changed', (payload) => {
|
|
107
109
|
if (payload && payload.path) {
|
|
108
110
|
if (payload.action === 'delete') {
|
|
109
111
|
this._deleteCache(payload.path);
|
|
@@ -114,11 +116,14 @@ class NexaApp {
|
|
|
114
116
|
this._notifySnapshotCallbacks(payload.path, payload.action === 'delete' ? 'document_deleted' : 'document_written', payload.data);
|
|
115
117
|
}
|
|
116
118
|
});
|
|
117
|
-
const unsub2 = this.
|
|
119
|
+
const unsub2 = this.wsClient.addListener('firestore_changed', (payload) => {
|
|
118
120
|
this._notifySnapshotCallbacks(payload.collectionPath || payload.docPath, 'server_update', payload.data);
|
|
119
121
|
});
|
|
120
122
|
this._firestoreUnsubscribes.push(unsub1, unsub2);
|
|
121
123
|
}
|
|
124
|
+
_ensureFirestoreWebSocket() {
|
|
125
|
+
this._ensureFirestoreSSE();
|
|
126
|
+
}
|
|
122
127
|
_closeFirestoreSSEIfIdle() {
|
|
123
128
|
if (Object.keys(this._snapshotCallbacks).length === 0) {
|
|
124
129
|
this._firestoreUnsubscribes.forEach((unsub) => unsub());
|
|
@@ -126,6 +131,9 @@ class NexaApp {
|
|
|
126
131
|
this._firestoreListening = false;
|
|
127
132
|
}
|
|
128
133
|
}
|
|
134
|
+
_closeFirestoreWebSocketIfIdle() {
|
|
135
|
+
this._closeFirestoreSSEIfIdle();
|
|
136
|
+
}
|
|
129
137
|
_notifySnapshotCallbacks(path, actionType, payload) {
|
|
130
138
|
Object.keys(this._snapshotCallbacks).forEach((cbKey) => {
|
|
131
139
|
const parts = cbKey.split('_');
|
|
@@ -180,5 +188,37 @@ class NexaApp {
|
|
|
180
188
|
async getDownloadURL(path) {
|
|
181
189
|
return this.storage.getDownloadURL(path);
|
|
182
190
|
}
|
|
191
|
+
isConnected() {
|
|
192
|
+
return this.wsClient.isConnected;
|
|
193
|
+
}
|
|
194
|
+
onConnectionStateChanged(callback) {
|
|
195
|
+
let lastState = this.isConnected();
|
|
196
|
+
callback(lastState);
|
|
197
|
+
// Make sure socket is created and connected if it isn't already
|
|
198
|
+
this.wsClient.ensureConnected();
|
|
199
|
+
const unsubOpen = this.wsClient.addListener('open', () => {
|
|
200
|
+
if (!lastState) {
|
|
201
|
+
lastState = true;
|
|
202
|
+
callback(true);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
const unsubClose = this.wsClient.addListener('close', () => {
|
|
206
|
+
if (lastState) {
|
|
207
|
+
lastState = false;
|
|
208
|
+
callback(false);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
const unsubError = this.wsClient.addListener('error', () => {
|
|
212
|
+
if (lastState) {
|
|
213
|
+
lastState = false;
|
|
214
|
+
callback(false);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
return () => {
|
|
218
|
+
unsubOpen();
|
|
219
|
+
unsubClose();
|
|
220
|
+
unsubError();
|
|
221
|
+
};
|
|
222
|
+
}
|
|
183
223
|
}
|
|
184
224
|
exports.NexaApp = NexaApp;
|
package/dist/firestore/writes.js
CHANGED
|
@@ -11,26 +11,21 @@ const setDoc = async (docRef, data, options) => {
|
|
|
11
11
|
await db._initPromise;
|
|
12
12
|
const existingData = options?.merge ? db._firestoreCache[docRef.path] || {} : {};
|
|
13
13
|
const finalData = (0, helpers_1.applyDataTransforms)(existingData, data);
|
|
14
|
-
await db._setCache(docRef.path, finalData);
|
|
15
|
-
db._broadcastLocalMutation(docRef.path, 'set', finalData);
|
|
16
|
-
db._notifySnapshotCallbacks(docRef.path, 'document_written', finalData);
|
|
17
14
|
const key = options?.idempotencyKey || (0, helpers_1.generateIdempotencyKey)();
|
|
18
15
|
const jobId = Math.random().toString(36).substring(2, 9);
|
|
19
16
|
const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
17
|
+
db.markInflight(docRef.path);
|
|
18
|
+
await db._setCache(docRef.path, finalData);
|
|
19
|
+
db._broadcastLocalMutation(docRef.path, 'set', finalData);
|
|
20
|
+
db._notifySnapshotCallbacks(docRef.path, 'document_written', finalData);
|
|
20
21
|
if (!isOnline) {
|
|
22
|
+
db.unmarkInflight(docRef.path);
|
|
21
23
|
await db._addOfflineJob({
|
|
22
|
-
id: jobId,
|
|
23
|
-
type: 'set',
|
|
24
|
-
path: docRef.path,
|
|
25
|
-
data,
|
|
26
|
-
idempotencyKey: key,
|
|
27
|
-
merge: options?.merge,
|
|
28
|
-
state: 'pending'
|
|
24
|
+
id: jobId, type: 'set', path: docRef.path, data, idempotencyKey: key, merge: options?.merge, state: 'pending'
|
|
29
25
|
});
|
|
30
26
|
return { success: true, message: 'Offline: Tersimpan di cache lokal' };
|
|
31
27
|
}
|
|
32
28
|
else {
|
|
33
|
-
db.markInflight(docRef.path);
|
|
34
29
|
try {
|
|
35
30
|
const res = (await db.client.post(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data, idempotencyKey: key, merge: options?.merge }, { headers: { 'X-Idempotency-Key': key } })).data;
|
|
36
31
|
return res;
|
|
@@ -62,13 +57,15 @@ const patchDoc = async (docRef, data, options) => {
|
|
|
62
57
|
await db._initPromise;
|
|
63
58
|
const existingData = db._firestoreCache[docRef.path] || {};
|
|
64
59
|
const finalData = (0, helpers_1.applyDataTransforms)(existingData, data);
|
|
65
|
-
await db._setCache(docRef.path, finalData);
|
|
66
|
-
db._broadcastLocalMutation(docRef.path, 'patch', finalData);
|
|
67
|
-
db._notifySnapshotCallbacks(docRef.path, 'document_written', finalData);
|
|
68
60
|
const key = options?.idempotencyKey || (0, helpers_1.generateIdempotencyKey)();
|
|
69
61
|
const jobId = Math.random().toString(36).substring(2, 9);
|
|
70
62
|
const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
63
|
+
db.markInflight(docRef.path);
|
|
64
|
+
await db._setCache(docRef.path, finalData);
|
|
65
|
+
db._broadcastLocalMutation(docRef.path, 'patch', finalData);
|
|
66
|
+
db._notifySnapshotCallbacks(docRef.path, 'document_written', finalData);
|
|
71
67
|
if (!isOnline) {
|
|
68
|
+
db.unmarkInflight(docRef.path);
|
|
72
69
|
await db._addOfflineJob({
|
|
73
70
|
id: jobId,
|
|
74
71
|
type: 'patch',
|
|
@@ -113,13 +110,15 @@ const deleteDoc = async (docRef, options) => {
|
|
|
113
110
|
const db = docRef.db;
|
|
114
111
|
if (db._initPromise)
|
|
115
112
|
await db._initPromise;
|
|
116
|
-
await db._deleteCache(docRef.path);
|
|
117
|
-
db._broadcastLocalMutation(docRef.path, 'delete');
|
|
118
|
-
db._notifySnapshotCallbacks(docRef.path, 'document_deleted', null);
|
|
119
113
|
const key = options?.idempotencyKey || (0, helpers_1.generateIdempotencyKey)();
|
|
120
114
|
const jobId = Math.random().toString(36).substring(2, 9);
|
|
121
115
|
const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
116
|
+
db.markInflight(docRef.path);
|
|
117
|
+
await db._deleteCache(docRef.path);
|
|
118
|
+
db._broadcastLocalMutation(docRef.path, 'delete');
|
|
119
|
+
db._notifySnapshotCallbacks(docRef.path, 'document_deleted', null);
|
|
122
120
|
if (!isOnline) {
|
|
121
|
+
db.unmarkInflight(docRef.path);
|
|
123
122
|
await db._addOfflineJob({
|
|
124
123
|
id: jobId,
|
|
125
124
|
type: 'delete',
|
package/dist/index.d.ts
CHANGED
|
@@ -30,6 +30,10 @@ export * from './sync/SyncEngine';
|
|
|
30
30
|
export * from './sync/ConflictResolver';
|
|
31
31
|
export * from './transport/HttpClient';
|
|
32
32
|
export * from './transport/SSEClient';
|
|
33
|
+
export * from './transport/WebSocketClient';
|
|
33
34
|
export * from './errors/NexaError';
|
|
34
35
|
export * from './types/index';
|
|
35
36
|
export * from './utils/helpers';
|
|
37
|
+
export * from './storage/types';
|
|
38
|
+
export * from './storage/NexaStorageError';
|
|
39
|
+
export { connectStorageEmulator } from './storage/Storage';
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.connectStorageEmulator = void 0;
|
|
17
18
|
// App
|
|
18
19
|
__exportStar(require("./app/initializeApp"), exports);
|
|
19
20
|
__exportStar(require("./app/NexaApp"), exports);
|
|
@@ -54,7 +55,12 @@ __exportStar(require("./sync/ConflictResolver"), exports);
|
|
|
54
55
|
// Transport
|
|
55
56
|
__exportStar(require("./transport/HttpClient"), exports);
|
|
56
57
|
__exportStar(require("./transport/SSEClient"), exports);
|
|
58
|
+
__exportStar(require("./transport/WebSocketClient"), exports);
|
|
57
59
|
// Errors & Types
|
|
58
60
|
__exportStar(require("./errors/NexaError"), exports);
|
|
59
61
|
__exportStar(require("./types/index"), exports);
|
|
60
62
|
__exportStar(require("./utils/helpers"), exports);
|
|
63
|
+
__exportStar(require("./storage/types"), exports);
|
|
64
|
+
__exportStar(require("./storage/NexaStorageError"), exports);
|
|
65
|
+
var Storage_1 = require("./storage/Storage");
|
|
66
|
+
Object.defineProperty(exports, "connectStorageEmulator", { enumerable: true, get: function () { return Storage_1.connectStorageEmulator; } });
|
|
@@ -74,5 +74,48 @@ class LocalStore {
|
|
|
74
74
|
req.onerror = () => reject(req.error);
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
|
+
// LRU Cache Garbage Collection (Max size 5000 items)
|
|
78
|
+
async garbageCollect(maxItems = 5000) {
|
|
79
|
+
const db = this.indexedDB.getRawDB();
|
|
80
|
+
if (!db)
|
|
81
|
+
return;
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const tx = db.transaction('offline_cache', 'readwrite');
|
|
84
|
+
const store = tx.objectStore('offline_cache');
|
|
85
|
+
const req = store.getAll();
|
|
86
|
+
req.onsuccess = () => {
|
|
87
|
+
const items = req.result;
|
|
88
|
+
if (items.length > maxItems) {
|
|
89
|
+
// Sort by timestamp (oldest first)
|
|
90
|
+
items.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
|
91
|
+
const itemsToRemove = items.slice(0, items.length - maxItems);
|
|
92
|
+
if (itemsToRemove.length === 0) {
|
|
93
|
+
return resolve();
|
|
94
|
+
}
|
|
95
|
+
let removedCount = 0;
|
|
96
|
+
itemsToRemove.forEach((item) => {
|
|
97
|
+
const delReq = store.delete(item.path);
|
|
98
|
+
delReq.onsuccess = () => {
|
|
99
|
+
delete this.inMemoryCache[item.path];
|
|
100
|
+
removedCount++;
|
|
101
|
+
if (removedCount === itemsToRemove.length) {
|
|
102
|
+
resolve();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
delReq.onerror = () => {
|
|
106
|
+
removedCount++;
|
|
107
|
+
if (removedCount === itemsToRemove.length) {
|
|
108
|
+
resolve();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
resolve();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
req.onerror = () => reject(req.error);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
77
120
|
}
|
|
78
121
|
exports.LocalStore = LocalStore;
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
+
import { WebSocketClient } from './WebSocketClient';
|
|
1
2
|
export declare class SSEClient {
|
|
2
|
-
private
|
|
3
|
-
private sseUrl;
|
|
4
|
-
private sse;
|
|
5
|
-
private isListening;
|
|
6
|
-
private listeners;
|
|
3
|
+
private wsClient;
|
|
7
4
|
constructor(projectId: string, sseUrl: string);
|
|
8
5
|
addListener(event: string, callback: (data: any) => void): () => void;
|
|
6
|
+
subscribe(path: string): void;
|
|
7
|
+
unsubscribe(path: string): void;
|
|
9
8
|
ensureConnected(): void;
|
|
10
9
|
checkCloseConnection(): void;
|
|
11
10
|
hasListeners(): boolean;
|
|
12
11
|
dispatch(event: string, data: any): void;
|
|
12
|
+
send(event: string, data: any): Promise<any>;
|
|
13
13
|
close(): void;
|
|
14
14
|
}
|
|
15
15
|
export { SSEClient as SSEManager };
|
|
16
|
+
export { WebSocketClient };
|
|
@@ -1,95 +1,38 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SSEManager = exports.SSEClient = void 0;
|
|
3
|
+
exports.WebSocketClient = exports.SSEManager = exports.SSEClient = void 0;
|
|
4
|
+
const WebSocketClient_1 = require("./WebSocketClient");
|
|
5
|
+
Object.defineProperty(exports, "WebSocketClient", { enumerable: true, get: function () { return WebSocketClient_1.WebSocketClient; } });
|
|
4
6
|
class SSEClient {
|
|
5
7
|
constructor(projectId, sseUrl) {
|
|
6
|
-
this.
|
|
7
|
-
this.isListening = false;
|
|
8
|
-
this.listeners = new Map();
|
|
9
|
-
this.projectId = projectId;
|
|
10
|
-
this.sseUrl = sseUrl;
|
|
8
|
+
this.wsClient = new WebSocketClient_1.WebSocketClient(projectId, sseUrl);
|
|
11
9
|
}
|
|
12
10
|
addListener(event, callback) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
this.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (callbacks) {
|
|
21
|
-
callbacks.delete(callback);
|
|
22
|
-
if (callbacks.size === 0) {
|
|
23
|
-
this.listeners.delete(event);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
this.checkCloseConnection();
|
|
27
|
-
};
|
|
11
|
+
return this.wsClient.addListener(event, callback);
|
|
12
|
+
}
|
|
13
|
+
subscribe(path) {
|
|
14
|
+
this.wsClient.subscribe(path);
|
|
15
|
+
}
|
|
16
|
+
unsubscribe(path) {
|
|
17
|
+
this.wsClient.unsubscribe(path);
|
|
28
18
|
}
|
|
29
19
|
ensureConnected() {
|
|
30
|
-
|
|
31
|
-
return;
|
|
32
|
-
this.isListening = true;
|
|
33
|
-
this.sse = new EventSource(`${this.sseUrl}/api/firestore/${this.projectId}/sse`);
|
|
34
|
-
this.sse.onopen = () => {
|
|
35
|
-
this.dispatch('open', null);
|
|
36
|
-
};
|
|
37
|
-
this.sse.onmessage = (event) => {
|
|
38
|
-
try {
|
|
39
|
-
const payload = JSON.parse(event.data);
|
|
40
|
-
this.dispatch(payload.event || 'message', payload.data || payload);
|
|
41
|
-
this.dispatch('all', payload);
|
|
42
|
-
}
|
|
43
|
-
catch (e) {
|
|
44
|
-
console.error('[SSEClient] Error parsing SSE payload:', e);
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
this.sse.onerror = (err) => {
|
|
48
|
-
this.dispatch('error', err);
|
|
49
|
-
if (this.sse) {
|
|
50
|
-
this.sse.close();
|
|
51
|
-
this.sse = null;
|
|
52
|
-
}
|
|
53
|
-
this.isListening = false;
|
|
54
|
-
if (this.hasListeners()) {
|
|
55
|
-
setTimeout(() => this.ensureConnected(), 3000);
|
|
56
|
-
}
|
|
57
|
-
};
|
|
20
|
+
this.wsClient.ensureConnected();
|
|
58
21
|
}
|
|
59
22
|
checkCloseConnection() {
|
|
60
|
-
|
|
61
|
-
this.sse.close();
|
|
62
|
-
this.sse = null;
|
|
63
|
-
this.isListening = false;
|
|
64
|
-
}
|
|
23
|
+
this.wsClient.checkCloseConnection();
|
|
65
24
|
}
|
|
66
25
|
hasListeners() {
|
|
67
|
-
|
|
68
|
-
this.listeners.forEach((set) => {
|
|
69
|
-
count += set.size;
|
|
70
|
-
});
|
|
71
|
-
return count > 0;
|
|
26
|
+
return this.wsClient.hasListeners();
|
|
72
27
|
}
|
|
73
28
|
dispatch(event, data) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
cb(data);
|
|
79
|
-
}
|
|
80
|
-
catch (e) {
|
|
81
|
-
console.error(`[SSEClient] Error in listener for event ${event}:`, e);
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
}
|
|
29
|
+
this.wsClient.dispatch(event, data);
|
|
30
|
+
}
|
|
31
|
+
send(event, data) {
|
|
32
|
+
return this.wsClient.send(event, data);
|
|
85
33
|
}
|
|
86
34
|
close() {
|
|
87
|
-
|
|
88
|
-
this.sse.close();
|
|
89
|
-
this.sse = null;
|
|
90
|
-
}
|
|
91
|
-
this.isListening = false;
|
|
92
|
-
this.listeners.clear();
|
|
35
|
+
this.wsClient.close();
|
|
93
36
|
}
|
|
94
37
|
}
|
|
95
38
|
exports.SSEClient = SSEClient;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare class WebSocketClient {
|
|
2
|
+
private projectId;
|
|
3
|
+
private serverUrl;
|
|
4
|
+
private socket;
|
|
5
|
+
isConnected: boolean;
|
|
6
|
+
private listeners;
|
|
7
|
+
private subscribedPaths;
|
|
8
|
+
private broadcastChannel;
|
|
9
|
+
private isLeader;
|
|
10
|
+
private hasInitialized;
|
|
11
|
+
constructor(projectId: string, serverUrl: string);
|
|
12
|
+
private handleBroadcastMessage;
|
|
13
|
+
addListener(event: string, callback: (data: any) => void): () => void;
|
|
14
|
+
subscribe(path: string): void;
|
|
15
|
+
unsubscribe(path: string): void;
|
|
16
|
+
private broadcastToFollowers;
|
|
17
|
+
ensureConnected(): void;
|
|
18
|
+
private startSocketConnection;
|
|
19
|
+
send(event: string, data: any): Promise<any>;
|
|
20
|
+
checkCloseConnection(): void;
|
|
21
|
+
hasListeners(): boolean;
|
|
22
|
+
dispatch(event: string, data: any): void;
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WebSocketClient = void 0;
|
|
4
|
+
const socket_io_client_1 = require("socket.io-client");
|
|
5
|
+
class WebSocketClient {
|
|
6
|
+
constructor(projectId, serverUrl) {
|
|
7
|
+
this.socket = null;
|
|
8
|
+
this.isConnected = false;
|
|
9
|
+
this.listeners = new Map();
|
|
10
|
+
this.subscribedPaths = new Set();
|
|
11
|
+
// Cross-tab sync properties
|
|
12
|
+
this.broadcastChannel = null;
|
|
13
|
+
this.isLeader = false;
|
|
14
|
+
this.hasInitialized = false;
|
|
15
|
+
this.projectId = projectId;
|
|
16
|
+
this.serverUrl = serverUrl;
|
|
17
|
+
if (typeof window !== 'undefined' && 'BroadcastChannel' in window) {
|
|
18
|
+
this.broadcastChannel = new BroadcastChannel(`nexabase_ws_${projectId}`);
|
|
19
|
+
this.broadcastChannel.onmessage = this.handleBroadcastMessage.bind(this);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
handleBroadcastMessage(event) {
|
|
23
|
+
const { type, payload } = event.data;
|
|
24
|
+
if (this.isLeader) {
|
|
25
|
+
if (type === 'follower_subscribe') {
|
|
26
|
+
this.socket?.emit('subscribe', this.projectId, payload.path);
|
|
27
|
+
}
|
|
28
|
+
else if (type === 'follower_unsubscribe') {
|
|
29
|
+
this.socket?.emit('unsubscribe', this.projectId, payload.path);
|
|
30
|
+
}
|
|
31
|
+
else if (type === 'follower_sync_request') {
|
|
32
|
+
this.broadcastChannel?.postMessage({ type: 'leader_state', payload: { isConnected: this.isConnected } });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
if (type === 'leader_event') {
|
|
37
|
+
const { event: socketEvent, data } = payload;
|
|
38
|
+
if (socketEvent === 'connect') {
|
|
39
|
+
this.isConnected = true;
|
|
40
|
+
this.dispatch('open', data);
|
|
41
|
+
}
|
|
42
|
+
else if (socketEvent === 'disconnect') {
|
|
43
|
+
this.isConnected = false;
|
|
44
|
+
this.dispatch('close', data);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
this.dispatch(socketEvent, data);
|
|
48
|
+
if (socketEvent === 'data_changed' || socketEvent === 'firestore_changed') {
|
|
49
|
+
this.dispatch('message', data);
|
|
50
|
+
this.dispatch('all', data);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else if (type === 'leader_state') {
|
|
55
|
+
this.isConnected = payload.isConnected;
|
|
56
|
+
if (this.isConnected) {
|
|
57
|
+
this.dispatch('open', {});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
addListener(event, callback) {
|
|
63
|
+
if (!this.listeners.has(event)) {
|
|
64
|
+
this.listeners.set(event, new Set());
|
|
65
|
+
}
|
|
66
|
+
this.listeners.get(event).add(callback);
|
|
67
|
+
this.ensureConnected();
|
|
68
|
+
return () => {
|
|
69
|
+
const callbacks = this.listeners.get(event);
|
|
70
|
+
if (callbacks) {
|
|
71
|
+
callbacks.delete(callback);
|
|
72
|
+
if (callbacks.size === 0) {
|
|
73
|
+
this.listeners.delete(event);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.checkCloseConnection();
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
subscribe(path) {
|
|
80
|
+
const cleanPath = (path || '').replace(/^\/+|\/+$/g, '');
|
|
81
|
+
this.subscribedPaths.add(cleanPath);
|
|
82
|
+
if (this.isLeader && this.socket && this.isConnected) {
|
|
83
|
+
this.socket.emit('subscribe', this.projectId, cleanPath);
|
|
84
|
+
}
|
|
85
|
+
else if (!this.isLeader && this.broadcastChannel) {
|
|
86
|
+
this.broadcastChannel.postMessage({ type: 'follower_subscribe', payload: { path: cleanPath } });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
unsubscribe(path) {
|
|
90
|
+
const cleanPath = (path || '').replace(/^\/+|\/+$/g, '');
|
|
91
|
+
this.subscribedPaths.delete(cleanPath);
|
|
92
|
+
if (this.isLeader && this.socket && this.isConnected) {
|
|
93
|
+
this.socket.emit('unsubscribe', this.projectId, cleanPath);
|
|
94
|
+
}
|
|
95
|
+
else if (!this.isLeader && this.broadcastChannel) {
|
|
96
|
+
this.broadcastChannel.postMessage({ type: 'follower_unsubscribe', payload: { path: cleanPath } });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
broadcastToFollowers(event, data) {
|
|
100
|
+
if (this.isLeader && this.broadcastChannel) {
|
|
101
|
+
this.broadcastChannel.postMessage({ type: 'leader_event', payload: { event, data } });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
ensureConnected() {
|
|
105
|
+
if (this.hasInitialized || typeof window === 'undefined')
|
|
106
|
+
return;
|
|
107
|
+
this.hasInitialized = true;
|
|
108
|
+
if (navigator.locks) {
|
|
109
|
+
navigator.locks.request(`nexabase_ws_leader_${this.projectId}`, { mode: 'exclusive', ifAvailable: false }, async (lock) => {
|
|
110
|
+
// This promise won't resolve until we explicitly return from it (which we only do on close)
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
this.isLeader = true;
|
|
113
|
+
this.startSocketConnection();
|
|
114
|
+
// If we are closed intentionally, resolve the lock
|
|
115
|
+
const originalClose = this.close.bind(this);
|
|
116
|
+
this.close = () => {
|
|
117
|
+
originalClose();
|
|
118
|
+
resolve();
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}).catch(() => {
|
|
122
|
+
// We are a follower
|
|
123
|
+
this.isLeader = false;
|
|
124
|
+
this.broadcastChannel?.postMessage({ type: 'follower_sync_request' });
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
// Fallback if no Web Locks API
|
|
129
|
+
this.isLeader = true;
|
|
130
|
+
this.startSocketConnection();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
startSocketConnection() {
|
|
134
|
+
this.socket = (0, socket_io_client_1.io)(this.serverUrl, {
|
|
135
|
+
transports: ['websocket', 'polling'],
|
|
136
|
+
autoConnect: true,
|
|
137
|
+
reconnection: true,
|
|
138
|
+
reconnectionDelay: 1000,
|
|
139
|
+
});
|
|
140
|
+
this.socket.on('connect', () => {
|
|
141
|
+
this.isConnected = true;
|
|
142
|
+
this.dispatch('open', { socketId: this.socket?.id });
|
|
143
|
+
this.broadcastToFollowers('connect', { socketId: this.socket?.id });
|
|
144
|
+
// Always join project room
|
|
145
|
+
this.socket?.emit('subscribe', this.projectId, '');
|
|
146
|
+
// Resubscribe to all paths
|
|
147
|
+
this.subscribedPaths.forEach((path) => {
|
|
148
|
+
this.socket?.emit('subscribe', this.projectId, path);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
this.socket.on('data_changed', (payload) => {
|
|
152
|
+
this.dispatch('data_changed', payload);
|
|
153
|
+
this.dispatch('message', payload);
|
|
154
|
+
this.dispatch('all', payload);
|
|
155
|
+
this.broadcastToFollowers('data_changed', payload);
|
|
156
|
+
});
|
|
157
|
+
this.socket.on('firestore_changed', (payload) => {
|
|
158
|
+
this.dispatch('firestore_changed', payload);
|
|
159
|
+
this.dispatch('message', payload);
|
|
160
|
+
this.dispatch('all', payload);
|
|
161
|
+
this.broadcastToFollowers('firestore_changed', payload);
|
|
162
|
+
});
|
|
163
|
+
this.socket.on('activity_logged', (payload) => {
|
|
164
|
+
this.dispatch('activity_logged', payload);
|
|
165
|
+
this.broadcastToFollowers('activity_logged', payload);
|
|
166
|
+
});
|
|
167
|
+
this.socket.on('stats_update', (payload) => {
|
|
168
|
+
this.dispatch('stats_update', payload);
|
|
169
|
+
this.broadcastToFollowers('stats_update', payload);
|
|
170
|
+
});
|
|
171
|
+
this.socket.on('connect_error', (err) => {
|
|
172
|
+
this.dispatch('error', err);
|
|
173
|
+
this.broadcastToFollowers('connect_error', err);
|
|
174
|
+
});
|
|
175
|
+
this.socket.on('disconnect', (reason) => {
|
|
176
|
+
this.isConnected = false;
|
|
177
|
+
this.dispatch('close', { reason });
|
|
178
|
+
this.broadcastToFollowers('disconnect', { reason });
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
send(event, data) {
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
this.ensureConnected();
|
|
184
|
+
if (!this.socket && this.isLeader) {
|
|
185
|
+
return reject(new Error('WebSocket client unavailable'));
|
|
186
|
+
}
|
|
187
|
+
if (!this.isLeader) {
|
|
188
|
+
// Followers can't send via websocket directly in this design without more plumbing.
|
|
189
|
+
// We fallback to standard HTTP API for writes anyway.
|
|
190
|
+
return reject(new Error('Cannot send WebSocket message from follower tab'));
|
|
191
|
+
}
|
|
192
|
+
this.socket.emit(event, data, (response) => {
|
|
193
|
+
if (response && response.error) {
|
|
194
|
+
reject(new Error(response.error));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
resolve(response);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
checkCloseConnection() {
|
|
203
|
+
if (!this.hasListeners() && this.subscribedPaths.size === 0 && this.socket) {
|
|
204
|
+
this.socket.disconnect();
|
|
205
|
+
this.socket = null;
|
|
206
|
+
this.isConnected = false;
|
|
207
|
+
this.hasInitialized = false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
hasListeners() {
|
|
211
|
+
let count = 0;
|
|
212
|
+
this.listeners.forEach((set) => {
|
|
213
|
+
count += set.size;
|
|
214
|
+
});
|
|
215
|
+
return count > 0;
|
|
216
|
+
}
|
|
217
|
+
dispatch(event, data) {
|
|
218
|
+
const callbacks = this.listeners.get(event);
|
|
219
|
+
if (callbacks) {
|
|
220
|
+
callbacks.forEach((cb) => {
|
|
221
|
+
try {
|
|
222
|
+
cb(data);
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
console.error(`[WebSocketClient] Error in listener for event ${event}:`, e);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
close() {
|
|
231
|
+
if (this.socket) {
|
|
232
|
+
this.socket.disconnect();
|
|
233
|
+
this.socket = null;
|
|
234
|
+
}
|
|
235
|
+
this.isConnected = false;
|
|
236
|
+
this.hasInitialized = false;
|
|
237
|
+
this.listeners.clear();
|
|
238
|
+
this.subscribedPaths.clear();
|
|
239
|
+
if (this.broadcastChannel) {
|
|
240
|
+
this.broadcastChannel.close();
|
|
241
|
+
this.broadcastChannel = null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
exports.WebSocketClient = WebSocketClient;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexabase-console",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"description": "SDK Client resmi untuk NexaBase: Platform Sinkronisasi NoSQL, Realtime, File Storage, & Autentikasi Offline-First.",
|
|
5
5
|
"main": "dist/index.cjs",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"author": "NexaBase Team",
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"axios": "^1.7.9"
|
|
29
|
+
"axios": "^1.7.9",
|
|
30
|
+
"socket.io-client": "^4.8.3"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"typescript": "^5.0.0"
|