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.
@@ -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
  }
@@ -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.sseClient.addListener('data_changed', (payload) => {
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.sseClient.addListener('firestore_changed', (payload) => {
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;
@@ -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; } });
@@ -9,4 +9,5 @@ export declare class LocalStore {
9
9
  get(path: string): Promise<any>;
10
10
  delete(path: string): Promise<void>;
11
11
  loadAll(): Promise<Record<string, any>>;
12
+ garbageCollect(maxItems?: number): Promise<void>;
12
13
  }
@@ -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 projectId;
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.sse = null;
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
- if (!this.listeners.has(event)) {
14
- this.listeners.set(event, new Set());
15
- }
16
- this.listeners.get(event).add(callback);
17
- this.ensureConnected();
18
- return () => {
19
- const callbacks = this.listeners.get(event);
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
- if (this.isListening || typeof window === 'undefined')
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
- if (!this.hasListeners() && this.sse) {
61
- this.sse.close();
62
- this.sse = null;
63
- this.isListening = false;
64
- }
23
+ this.wsClient.checkCloseConnection();
65
24
  }
66
25
  hasListeners() {
67
- let count = 0;
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
- const callbacks = this.listeners.get(event);
75
- if (callbacks) {
76
- callbacks.forEach((cb) => {
77
- try {
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
- if (this.sse) {
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.6",
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"