nexabase-console 2.0.6 → 2.0.8

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,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;
@@ -140,14 +140,21 @@ export interface WriteBatch {
140
140
  }
141
141
  export interface OfflineJob {
142
142
  id: string;
143
- type: 'set' | 'update' | 'patch' | 'delete';
143
+ type: 'set' | 'patch' | 'delete';
144
144
  path: string;
145
145
  data?: any;
146
146
  idempotencyKey?: string;
147
+ precondition?: {
148
+ exists?: boolean;
149
+ updateTime?: string;
150
+ };
147
151
  timestamp?: number;
148
152
  merge?: boolean;
149
153
  state?: 'pending' | 'acknowledged' | 'rejected';
150
154
  retryCount?: number;
155
+ leaseOwner?: string;
156
+ leaseExpiresAt?: number;
157
+ sequenceNumber?: number;
151
158
  lastError?: string;
152
159
  }
153
160
  export interface Transaction {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexabase-console",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
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"