nexabase-console 2.1.8 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/firestore/FieldValue.d.ts +6 -0
- package/dist/firestore/FieldValue.js +14 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sync/CrossTabSync.d.ts +83 -0
- package/dist/sync/CrossTabSync.js +317 -0
- package/dist/sync/SyncEngine.d.ts +5 -5
- package/dist/sync/SyncEngine.js +7 -67
- package/dist/utils/helpers.js +48 -15
- package/package.json +1 -1
|
@@ -2,10 +2,16 @@ export declare const FIELD_VALUE_BRAND: unique symbol;
|
|
|
2
2
|
export type FieldValueMethod = 'serverTimestamp' | 'increment' | 'arrayUnion' | 'arrayRemove' | 'deleteField';
|
|
3
3
|
export declare class FieldValue {
|
|
4
4
|
readonly [FIELD_VALUE_BRAND] = true;
|
|
5
|
+
readonly _isFieldValue: boolean;
|
|
5
6
|
readonly method: FieldValueMethod;
|
|
6
7
|
readonly value?: any;
|
|
7
8
|
constructor(method: FieldValueMethod, value?: any);
|
|
8
9
|
static isFieldValue(val: any): val is FieldValue;
|
|
10
|
+
toJSON(): {
|
|
11
|
+
_isFieldValue: boolean;
|
|
12
|
+
method: FieldValueMethod;
|
|
13
|
+
value: any;
|
|
14
|
+
};
|
|
9
15
|
isEqual(other: any): boolean;
|
|
10
16
|
}
|
|
11
17
|
export declare const serverTimestamp: () => FieldValue;
|
|
@@ -7,14 +7,27 @@ exports.FIELD_VALUE_BRAND = Symbol.for('nexabase.fieldValue');
|
|
|
7
7
|
class FieldValue {
|
|
8
8
|
constructor(method, value) {
|
|
9
9
|
this[_a] = true;
|
|
10
|
+
this._isFieldValue = true;
|
|
10
11
|
this.method = method;
|
|
11
12
|
this.value = value;
|
|
13
|
+
this._isFieldValue = true;
|
|
12
14
|
Object.freeze(this);
|
|
13
15
|
}
|
|
14
16
|
static isFieldValue(val) {
|
|
15
17
|
return (val !== null &&
|
|
16
18
|
typeof val === 'object' &&
|
|
17
|
-
(val instanceof FieldValue ||
|
|
19
|
+
(val instanceof FieldValue ||
|
|
20
|
+
val[exports.FIELD_VALUE_BRAND] === true ||
|
|
21
|
+
val._isFieldValue === true ||
|
|
22
|
+
(typeof val.method === 'string' &&
|
|
23
|
+
['serverTimestamp', 'increment', 'arrayUnion', 'arrayRemove', 'deleteField'].includes(val.method))));
|
|
24
|
+
}
|
|
25
|
+
toJSON() {
|
|
26
|
+
return {
|
|
27
|
+
_isFieldValue: true,
|
|
28
|
+
method: this.method,
|
|
29
|
+
value: this.value !== undefined ? this.value : undefined
|
|
30
|
+
};
|
|
18
31
|
}
|
|
19
32
|
isEqual(other) {
|
|
20
33
|
if (!FieldValue.isFieldValue(other))
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ export * from './persistence/LocalStore';
|
|
|
28
28
|
export * from './persistence/MutationQueue';
|
|
29
29
|
export * from './sync/SyncEngine';
|
|
30
30
|
export * from './sync/ConflictResolver';
|
|
31
|
+
export * from './sync/CrossTabSync';
|
|
31
32
|
export * from './transport/HttpClient';
|
|
32
33
|
export * from './transport/SSEClient';
|
|
33
34
|
export * from './transport/WebSocketClient';
|
package/dist/index.js
CHANGED
|
@@ -52,6 +52,7 @@ __exportStar(require("./persistence/MutationQueue"), exports);
|
|
|
52
52
|
// Sync
|
|
53
53
|
__exportStar(require("./sync/SyncEngine"), exports);
|
|
54
54
|
__exportStar(require("./sync/ConflictResolver"), exports);
|
|
55
|
+
__exportStar(require("./sync/CrossTabSync"), exports);
|
|
55
56
|
// Transport
|
|
56
57
|
__exportStar(require("./transport/HttpClient"), exports);
|
|
57
58
|
__exportStar(require("./transport/SSEClient"), exports);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export interface CrossTabMessage<T = any> {
|
|
2
|
+
id: string;
|
|
3
|
+
senderTabId: string;
|
|
4
|
+
projectId?: string;
|
|
5
|
+
type: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
payload: T;
|
|
8
|
+
}
|
|
9
|
+
export interface LocalMutationPayload {
|
|
10
|
+
path: string;
|
|
11
|
+
action: 'set' | 'patch' | 'update' | 'delete' | string;
|
|
12
|
+
data?: any;
|
|
13
|
+
metadata?: {
|
|
14
|
+
mutationId?: string;
|
|
15
|
+
source?: 'local' | 'server' | string;
|
|
16
|
+
state?: 'pending' | 'acknowledged' | string;
|
|
17
|
+
hasPendingWrites?: boolean;
|
|
18
|
+
[key: string]: any;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export type CrossTabMessageHandler<T = any> = (payload: T, message: CrossTabMessage<T>) => void;
|
|
22
|
+
export type MutationSyncHandler = (path: string, action: string, data?: any, metadata?: LocalMutationPayload['metadata']) => void;
|
|
23
|
+
/**
|
|
24
|
+
* Serializes rich JavaScript objects (Timestamp, GeoPoint, Date) into structured JSON-safe
|
|
25
|
+
* objects with explicit type tags so they can cross BroadcastChannel or localStorage boundaries safely.
|
|
26
|
+
*/
|
|
27
|
+
export declare function serializeCrossTabData(data: any, seen?: WeakSet<object>): any;
|
|
28
|
+
/**
|
|
29
|
+
* Deserializes structured JSON-safe objects back into rich JavaScript types (Timestamp, GeoPoint, etc.).
|
|
30
|
+
*/
|
|
31
|
+
export declare function deserializeCrossTabData(data: any): any;
|
|
32
|
+
/**
|
|
33
|
+
* Checks if native BroadcastChannel is supported in the current environment.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isBroadcastChannelSupported(): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* CrossTabSyncChannel provides ultra-fast, local cross-tab state synchronization
|
|
38
|
+
* with native BroadcastChannel support and automatic fallback to localStorage storage events.
|
|
39
|
+
*/
|
|
40
|
+
export declare class CrossTabSyncChannel {
|
|
41
|
+
readonly channelName: string;
|
|
42
|
+
readonly tabId: string;
|
|
43
|
+
private broadcastChannel;
|
|
44
|
+
private listeners;
|
|
45
|
+
private wildcardListeners;
|
|
46
|
+
private storageListener;
|
|
47
|
+
private isClosed;
|
|
48
|
+
constructor(channelName: string);
|
|
49
|
+
private initTransport;
|
|
50
|
+
private handleIncomingRawMessage;
|
|
51
|
+
/**
|
|
52
|
+
* Broadcasts a typed message across all other browser tabs.
|
|
53
|
+
*/
|
|
54
|
+
broadcast<T = any>(type: string, payload: T, projectId?: string): void;
|
|
55
|
+
/**
|
|
56
|
+
* Broadcasts a local mutation event (write, update, delete) to keep caches across tabs in sync instantly.
|
|
57
|
+
*/
|
|
58
|
+
broadcastMutation(path: string, action: string, data?: any, metadata?: LocalMutationPayload['metadata'], projectId?: string): void;
|
|
59
|
+
/**
|
|
60
|
+
* Subscribes to a specific event type. Returns an unsubscribe callback.
|
|
61
|
+
*/
|
|
62
|
+
on<T = any>(type: string, handler: CrossTabMessageHandler<T>): () => void;
|
|
63
|
+
/**
|
|
64
|
+
* Subscribes to all incoming messages regardless of type.
|
|
65
|
+
*/
|
|
66
|
+
onAny(handler: CrossTabMessageHandler): () => void;
|
|
67
|
+
/**
|
|
68
|
+
* Helper specifically for listening to local mutations across tabs.
|
|
69
|
+
*/
|
|
70
|
+
onMutation(handler: MutationSyncHandler): () => void;
|
|
71
|
+
/**
|
|
72
|
+
* Closes the channel and removes all event listeners.
|
|
73
|
+
*/
|
|
74
|
+
close(): void;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Returns a singleton instance of CrossTabSyncChannel for the given channel name.
|
|
78
|
+
*/
|
|
79
|
+
export declare function getCrossTabChannel(channelName: string): CrossTabSyncChannel;
|
|
80
|
+
/**
|
|
81
|
+
* Creates or gets a project-scoped cross-tab synchronization channel.
|
|
82
|
+
*/
|
|
83
|
+
export declare function getProjectCrossTabChannel(projectId: string): CrossTabSyncChannel;
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CrossTabSyncChannel = void 0;
|
|
4
|
+
exports.serializeCrossTabData = serializeCrossTabData;
|
|
5
|
+
exports.deserializeCrossTabData = deserializeCrossTabData;
|
|
6
|
+
exports.isBroadcastChannelSupported = isBroadcastChannelSupported;
|
|
7
|
+
exports.getCrossTabChannel = getCrossTabChannel;
|
|
8
|
+
exports.getProjectCrossTabChannel = getProjectCrossTabChannel;
|
|
9
|
+
const Timestamp_1 = require("../firestore/Timestamp");
|
|
10
|
+
const GeoPoint_1 = require("../firestore/GeoPoint");
|
|
11
|
+
const QueryUtils_1 = require("../firestore/QueryUtils");
|
|
12
|
+
/**
|
|
13
|
+
* Generates a unique Tab ID for distinguishing cross-tab messages from current tab.
|
|
14
|
+
*/
|
|
15
|
+
function createTabId() {
|
|
16
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
17
|
+
return crypto.randomUUID();
|
|
18
|
+
}
|
|
19
|
+
return `tab_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Serializes rich JavaScript objects (Timestamp, GeoPoint, Date) into structured JSON-safe
|
|
23
|
+
* objects with explicit type tags so they can cross BroadcastChannel or localStorage boundaries safely.
|
|
24
|
+
*/
|
|
25
|
+
function serializeCrossTabData(data, seen = new WeakSet()) {
|
|
26
|
+
if (data === null || data === undefined)
|
|
27
|
+
return data;
|
|
28
|
+
const type = typeof data;
|
|
29
|
+
if (type === 'boolean' || type === 'number' || type === 'string')
|
|
30
|
+
return data;
|
|
31
|
+
if (data instanceof Date) {
|
|
32
|
+
return {
|
|
33
|
+
__nexa_type__: 'timestamp',
|
|
34
|
+
seconds: Math.floor(data.getTime() / 1000),
|
|
35
|
+
nanoseconds: (data.getTime() % 1000) * 1000000
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (data instanceof Timestamp_1.Timestamp) {
|
|
39
|
+
return {
|
|
40
|
+
__nexa_type__: 'timestamp',
|
|
41
|
+
seconds: data.seconds,
|
|
42
|
+
nanoseconds: data.nanoseconds
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// Handle Timestamp-like objects (e.g. if prototype was stripped)
|
|
46
|
+
if (typeof data === 'object' &&
|
|
47
|
+
'seconds' in data &&
|
|
48
|
+
'nanoseconds' in data &&
|
|
49
|
+
typeof data.seconds === 'number' &&
|
|
50
|
+
typeof data.nanoseconds === 'number') {
|
|
51
|
+
return {
|
|
52
|
+
__nexa_type__: 'timestamp',
|
|
53
|
+
seconds: data.seconds,
|
|
54
|
+
nanoseconds: data.nanoseconds
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (data instanceof GeoPoint_1.GeoPoint) {
|
|
58
|
+
return {
|
|
59
|
+
__nexa_type__: 'geopoint',
|
|
60
|
+
latitude: data.latitude,
|
|
61
|
+
longitude: data.longitude
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (typeof data === 'object' &&
|
|
65
|
+
'latitude' in data &&
|
|
66
|
+
'longitude' in data &&
|
|
67
|
+
typeof data.latitude === 'number' &&
|
|
68
|
+
typeof data.longitude === 'number') {
|
|
69
|
+
return {
|
|
70
|
+
__nexa_type__: 'geopoint',
|
|
71
|
+
latitude: data.latitude,
|
|
72
|
+
longitude: data.longitude
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(data)) {
|
|
76
|
+
if (seen.has(data))
|
|
77
|
+
return null;
|
|
78
|
+
seen.add(data);
|
|
79
|
+
const res = data.map((item) => serializeCrossTabData(item, seen));
|
|
80
|
+
seen.delete(data);
|
|
81
|
+
return res;
|
|
82
|
+
}
|
|
83
|
+
if (typeof data === 'object') {
|
|
84
|
+
if (seen.has(data))
|
|
85
|
+
return null;
|
|
86
|
+
seen.add(data);
|
|
87
|
+
const res = {};
|
|
88
|
+
for (const key of Object.keys(data)) {
|
|
89
|
+
res[key] = serializeCrossTabData(data[key], seen);
|
|
90
|
+
}
|
|
91
|
+
seen.delete(data);
|
|
92
|
+
return res;
|
|
93
|
+
}
|
|
94
|
+
return data;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Deserializes structured JSON-safe objects back into rich JavaScript types (Timestamp, GeoPoint, etc.).
|
|
98
|
+
*/
|
|
99
|
+
function deserializeCrossTabData(data) {
|
|
100
|
+
return (0, QueryUtils_1.reviveNexaData)(data);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Checks if native BroadcastChannel is supported in the current environment.
|
|
104
|
+
*/
|
|
105
|
+
function isBroadcastChannelSupported() {
|
|
106
|
+
return typeof window !== 'undefined' && typeof window.BroadcastChannel !== 'undefined';
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* CrossTabSyncChannel provides ultra-fast, local cross-tab state synchronization
|
|
110
|
+
* with native BroadcastChannel support and automatic fallback to localStorage storage events.
|
|
111
|
+
*/
|
|
112
|
+
class CrossTabSyncChannel {
|
|
113
|
+
constructor(channelName) {
|
|
114
|
+
this.broadcastChannel = null;
|
|
115
|
+
this.listeners = new Map();
|
|
116
|
+
this.wildcardListeners = new Set();
|
|
117
|
+
this.storageListener = null;
|
|
118
|
+
this.isClosed = false;
|
|
119
|
+
this.channelName = channelName;
|
|
120
|
+
this.tabId = createTabId();
|
|
121
|
+
this.initTransport();
|
|
122
|
+
}
|
|
123
|
+
initTransport() {
|
|
124
|
+
if (typeof window === 'undefined')
|
|
125
|
+
return;
|
|
126
|
+
if (isBroadcastChannelSupported()) {
|
|
127
|
+
try {
|
|
128
|
+
this.broadcastChannel = new window.BroadcastChannel(this.channelName);
|
|
129
|
+
this.broadcastChannel.onmessage = (event) => {
|
|
130
|
+
this.handleIncomingRawMessage(event.data);
|
|
131
|
+
};
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.warn(`[CrossTabSyncChannel] Native BroadcastChannel failed for "${this.channelName}", falling back to StorageEvent:`, err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Fallback: localStorage storage events
|
|
139
|
+
if (typeof window.addEventListener === 'function' && typeof window.localStorage !== 'undefined') {
|
|
140
|
+
const storageKey = `__nexa_crosstab_${this.channelName}`;
|
|
141
|
+
this.storageListener = (e) => {
|
|
142
|
+
if (e.key === storageKey && e.newValue) {
|
|
143
|
+
try {
|
|
144
|
+
const raw = JSON.parse(e.newValue);
|
|
145
|
+
this.handleIncomingRawMessage(raw);
|
|
146
|
+
}
|
|
147
|
+
catch (parseErr) {
|
|
148
|
+
// Ignore parse errors from concurrent writes
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
window.addEventListener('storage', this.storageListener);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
handleIncomingRawMessage(raw) {
|
|
156
|
+
if (!raw || typeof raw !== 'object')
|
|
157
|
+
return;
|
|
158
|
+
const msg = raw;
|
|
159
|
+
// Discard messages originating from the current tab
|
|
160
|
+
if (msg.senderTabId === this.tabId)
|
|
161
|
+
return;
|
|
162
|
+
// Reconstruct rich objects (Timestamp, GeoPoint)
|
|
163
|
+
const revivedPayload = deserializeCrossTabData(msg.payload);
|
|
164
|
+
const message = {
|
|
165
|
+
...msg,
|
|
166
|
+
payload: revivedPayload
|
|
167
|
+
};
|
|
168
|
+
// Notify specific type listeners
|
|
169
|
+
if (msg.type && this.listeners.has(msg.type)) {
|
|
170
|
+
const typeSet = this.listeners.get(msg.type);
|
|
171
|
+
typeSet.forEach((fn) => {
|
|
172
|
+
try {
|
|
173
|
+
fn(revivedPayload, message);
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
console.error(`[CrossTabSyncChannel] Error in listener for type "${msg.type}":`, e);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
// Notify wildcard listeners
|
|
181
|
+
this.wildcardListeners.forEach((fn) => {
|
|
182
|
+
try {
|
|
183
|
+
fn(revivedPayload, message);
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
console.error(`[CrossTabSyncChannel] Error in wildcard listener:`, e);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Broadcasts a typed message across all other browser tabs.
|
|
192
|
+
*/
|
|
193
|
+
broadcast(type, payload, projectId) {
|
|
194
|
+
if (this.isClosed)
|
|
195
|
+
return;
|
|
196
|
+
const serializedPayload = serializeCrossTabData(payload);
|
|
197
|
+
const message = {
|
|
198
|
+
id: `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
199
|
+
senderTabId: this.tabId,
|
|
200
|
+
projectId,
|
|
201
|
+
type,
|
|
202
|
+
timestamp: Date.now(),
|
|
203
|
+
payload: serializedPayload
|
|
204
|
+
};
|
|
205
|
+
// 1. Native BroadcastChannel
|
|
206
|
+
if (this.broadcastChannel) {
|
|
207
|
+
try {
|
|
208
|
+
this.broadcastChannel.postMessage(message);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
console.warn('[CrossTabSyncChannel] BroadcastChannel postMessage failed:', e);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// 2. StorageEvent Fallback
|
|
216
|
+
if (typeof window !== 'undefined' && typeof window.localStorage !== 'undefined') {
|
|
217
|
+
try {
|
|
218
|
+
const storageKey = `__nexa_crosstab_${this.channelName}`;
|
|
219
|
+
window.localStorage.setItem(storageKey, JSON.stringify(message));
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
// Storage quota or restriction
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Broadcasts a local mutation event (write, update, delete) to keep caches across tabs in sync instantly.
|
|
228
|
+
*/
|
|
229
|
+
broadcastMutation(path, action, data, metadata, projectId) {
|
|
230
|
+
const payload = {
|
|
231
|
+
path,
|
|
232
|
+
action,
|
|
233
|
+
data,
|
|
234
|
+
metadata
|
|
235
|
+
};
|
|
236
|
+
this.broadcast('LOCAL_MUTATION', payload, projectId);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Subscribes to a specific event type. Returns an unsubscribe callback.
|
|
240
|
+
*/
|
|
241
|
+
on(type, handler) {
|
|
242
|
+
if (!this.listeners.has(type)) {
|
|
243
|
+
this.listeners.set(type, new Set());
|
|
244
|
+
}
|
|
245
|
+
this.listeners.get(type).add(handler);
|
|
246
|
+
return () => {
|
|
247
|
+
const set = this.listeners.get(type);
|
|
248
|
+
if (set) {
|
|
249
|
+
set.delete(handler);
|
|
250
|
+
if (set.size === 0) {
|
|
251
|
+
this.listeners.delete(type);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Subscribes to all incoming messages regardless of type.
|
|
258
|
+
*/
|
|
259
|
+
onAny(handler) {
|
|
260
|
+
this.wildcardListeners.add(handler);
|
|
261
|
+
return () => {
|
|
262
|
+
this.wildcardListeners.delete(handler);
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Helper specifically for listening to local mutations across tabs.
|
|
267
|
+
*/
|
|
268
|
+
onMutation(handler) {
|
|
269
|
+
return this.on('LOCAL_MUTATION', (payload) => {
|
|
270
|
+
if (payload && payload.path && payload.action) {
|
|
271
|
+
handler(payload.path, payload.action, payload.data, payload.metadata);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Closes the channel and removes all event listeners.
|
|
277
|
+
*/
|
|
278
|
+
close() {
|
|
279
|
+
this.isClosed = true;
|
|
280
|
+
this.listeners.clear();
|
|
281
|
+
this.wildcardListeners.clear();
|
|
282
|
+
if (this.broadcastChannel) {
|
|
283
|
+
try {
|
|
284
|
+
this.broadcastChannel.close();
|
|
285
|
+
}
|
|
286
|
+
catch (e) { }
|
|
287
|
+
this.broadcastChannel = null;
|
|
288
|
+
}
|
|
289
|
+
if (this.storageListener && typeof window !== 'undefined') {
|
|
290
|
+
try {
|
|
291
|
+
window.removeEventListener('storage', this.storageListener);
|
|
292
|
+
}
|
|
293
|
+
catch (e) { }
|
|
294
|
+
this.storageListener = null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
exports.CrossTabSyncChannel = CrossTabSyncChannel;
|
|
299
|
+
// Global registry for CrossTab channels by name
|
|
300
|
+
const channelsRegistry = new Map();
|
|
301
|
+
/**
|
|
302
|
+
* Returns a singleton instance of CrossTabSyncChannel for the given channel name.
|
|
303
|
+
*/
|
|
304
|
+
function getCrossTabChannel(channelName) {
|
|
305
|
+
let channel = channelsRegistry.get(channelName);
|
|
306
|
+
if (!channel) {
|
|
307
|
+
channel = new CrossTabSyncChannel(channelName);
|
|
308
|
+
channelsRegistry.set(channelName, channel);
|
|
309
|
+
}
|
|
310
|
+
return channel;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Creates or gets a project-scoped cross-tab synchronization channel.
|
|
314
|
+
*/
|
|
315
|
+
function getProjectCrossTabChannel(projectId) {
|
|
316
|
+
return getCrossTabChannel(`nexabase_tab_sync_${projectId}`);
|
|
317
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { MutationQueue } from '../persistence/MutationQueue';
|
|
2
2
|
import { SSEClient } from '../transport/SSEClient';
|
|
3
|
+
import { CrossTabSyncChannel, MutationSyncHandler } from './CrossTabSync';
|
|
3
4
|
export declare class SyncEngine {
|
|
4
5
|
private projectId;
|
|
5
6
|
private mutationQueue;
|
|
6
7
|
private sseClient;
|
|
7
|
-
private
|
|
8
|
+
private crossTabChannel;
|
|
8
9
|
constructor(projectId: string, mutationQueue: MutationQueue, sseClient: SSEClient);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
listenCrossTab(onMutation: (path: string, action: string, data?: any) => void): () => void;
|
|
10
|
+
getCrossTabChannel(): CrossTabSyncChannel;
|
|
11
|
+
broadcastLocalMutation(path: string, action: string, data?: any, metadata?: any): void;
|
|
12
|
+
listenCrossTab(onMutation: MutationSyncHandler): () => void;
|
|
13
13
|
syncOfflineMutations(): Promise<void>;
|
|
14
14
|
}
|
package/dist/sync/SyncEngine.js
CHANGED
|
@@ -1,82 +1,22 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SyncEngine = void 0;
|
|
4
|
+
const CrossTabSync_1 = require("./CrossTabSync");
|
|
4
5
|
class SyncEngine {
|
|
5
6
|
constructor(projectId, mutationQueue, sseClient) {
|
|
6
|
-
this.broadcastChannel = null;
|
|
7
7
|
this.projectId = projectId;
|
|
8
8
|
this.mutationQueue = mutationQueue;
|
|
9
9
|
this.sseClient = sseClient;
|
|
10
|
-
this.
|
|
10
|
+
this.crossTabChannel = (0, CrossTabSync_1.getProjectCrossTabChannel)(this.projectId);
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
this.broadcastChannel = new BroadcastChannel(`nexabase_tab_sync_${this.projectId}`);
|
|
16
|
-
}
|
|
17
|
-
catch (e) {
|
|
18
|
-
console.warn('[SyncEngine] BroadcastChannel initialization failed:', e);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
12
|
+
getCrossTabChannel() {
|
|
13
|
+
return this.crossTabChannel;
|
|
21
14
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return data;
|
|
25
|
-
if (typeof data === 'boolean' || typeof data === 'number' || typeof data === 'string')
|
|
26
|
-
return data;
|
|
27
|
-
if (data instanceof Date) {
|
|
28
|
-
return { __nexa_type__: 'timestamp', seconds: Math.floor(data.getTime() / 1000), nanoseconds: (data.getTime() % 1000) * 1000000 };
|
|
29
|
-
}
|
|
30
|
-
// Check if it's a Timestamp object (even without prototype due to potential prior cloning)
|
|
31
|
-
if (typeof data === 'object' && 'seconds' in data && 'nanoseconds' in data && typeof data.seconds === 'number' && typeof data.nanoseconds === 'number') {
|
|
32
|
-
return { __nexa_type__: 'timestamp', seconds: data.seconds, nanoseconds: data.nanoseconds };
|
|
33
|
-
}
|
|
34
|
-
// Check for GeoPoint
|
|
35
|
-
if (typeof data === 'object' && 'latitude' in data && 'longitude' in data && typeof data.latitude === 'number' && typeof data.longitude === 'number') {
|
|
36
|
-
return { __nexa_type__: 'geopoint', latitude: data.latitude, longitude: data.longitude };
|
|
37
|
-
}
|
|
38
|
-
if (Array.isArray(data)) {
|
|
39
|
-
if (seen.has(data))
|
|
40
|
-
return null;
|
|
41
|
-
seen.add(data);
|
|
42
|
-
const res = data.map(item => this._serializeForBroadcast(item, seen));
|
|
43
|
-
seen.delete(data);
|
|
44
|
-
return res;
|
|
45
|
-
}
|
|
46
|
-
if (typeof data === 'object') {
|
|
47
|
-
if (seen.has(data))
|
|
48
|
-
return null;
|
|
49
|
-
seen.add(data);
|
|
50
|
-
const res = {};
|
|
51
|
-
for (const key of Object.keys(data)) {
|
|
52
|
-
res[key] = this._serializeForBroadcast(data[key], seen);
|
|
53
|
-
}
|
|
54
|
-
seen.delete(data);
|
|
55
|
-
return res;
|
|
56
|
-
}
|
|
57
|
-
return data;
|
|
58
|
-
}
|
|
59
|
-
broadcastLocalMutation(path, action, data) {
|
|
60
|
-
if (this.broadcastChannel) {
|
|
61
|
-
try {
|
|
62
|
-
const serialized = this._serializeForBroadcast(data);
|
|
63
|
-
this.broadcastChannel.postMessage({ type: 'LOCAL_MUTATION', path, action, data: serialized });
|
|
64
|
-
}
|
|
65
|
-
catch (e) { }
|
|
66
|
-
}
|
|
15
|
+
broadcastLocalMutation(path, action, data, metadata) {
|
|
16
|
+
this.crossTabChannel.broadcastMutation(path, action, data, metadata, this.projectId);
|
|
67
17
|
}
|
|
68
18
|
listenCrossTab(onMutation) {
|
|
69
|
-
|
|
70
|
-
return () => { };
|
|
71
|
-
const handler = (event) => {
|
|
72
|
-
if (event.data && event.data.type === 'LOCAL_MUTATION') {
|
|
73
|
-
onMutation(event.data.path, event.data.action, event.data.data);
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
this.broadcastChannel.addEventListener('message', handler);
|
|
77
|
-
return () => {
|
|
78
|
-
this.broadcastChannel.removeEventListener('message', handler);
|
|
79
|
-
};
|
|
19
|
+
return this.crossTabChannel.onMutation(onMutation);
|
|
80
20
|
}
|
|
81
21
|
async syncOfflineMutations() {
|
|
82
22
|
await this.mutationQueue.syncQueue();
|
package/dist/utils/helpers.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.deepEqual = deepEqual;
|
|
|
5
5
|
exports.applyDataTransforms = applyDataTransforms;
|
|
6
6
|
exports.calculateDocChanges = calculateDocChanges;
|
|
7
7
|
exports.generateIdempotencyKey = generateIdempotencyKey;
|
|
8
|
+
const FieldValue_1 = require("../firestore/FieldValue");
|
|
8
9
|
const Timestamp_1 = require("../firestore/Timestamp");
|
|
9
10
|
function getNestedValue(obj, path, docId) {
|
|
10
11
|
if (!obj || !path)
|
|
@@ -46,34 +47,61 @@ function deepEqual(a, b) {
|
|
|
46
47
|
return true;
|
|
47
48
|
}
|
|
48
49
|
function applyDataTransforms(existingData, updateData) {
|
|
49
|
-
|
|
50
|
+
if (!updateData || typeof updateData !== 'object')
|
|
51
|
+
return existingData || {};
|
|
52
|
+
const result = { ...(existingData || {}) };
|
|
50
53
|
const processNode = (obj, keyPath, value) => {
|
|
54
|
+
if (!obj || typeof obj !== 'object')
|
|
55
|
+
return;
|
|
51
56
|
const current = keyPath[0];
|
|
52
57
|
if (keyPath.length === 1) {
|
|
53
|
-
if (value
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
if (FieldValue_1.FieldValue.isFieldValue(value) ||
|
|
59
|
+
(value &&
|
|
60
|
+
typeof value === 'object' &&
|
|
61
|
+
(value._isFieldValue ||
|
|
62
|
+
['deleteField', 'serverTimestamp', 'increment', 'arrayUnion', 'arrayRemove'].includes(value.method)))) {
|
|
63
|
+
const method = value.method;
|
|
64
|
+
const val = value.value;
|
|
65
|
+
if (method === 'serverTimestamp') {
|
|
56
66
|
obj[current] = Timestamp_1.Timestamp.now();
|
|
57
67
|
}
|
|
58
|
-
else if (
|
|
59
|
-
obj[current] = (typeof obj[current] === 'number' ? obj[current] : 0) + (
|
|
68
|
+
else if (method === 'increment') {
|
|
69
|
+
obj[current] = (typeof obj[current] === 'number' ? obj[current] : 0) + (typeof val === 'number' ? val : 1);
|
|
60
70
|
}
|
|
61
|
-
else if (
|
|
71
|
+
else if (method === 'arrayUnion') {
|
|
62
72
|
const arr = Array.isArray(obj[current]) ? obj[current] : [];
|
|
63
|
-
|
|
73
|
+
const toAdd = Array.isArray(val) ? val : (val !== undefined ? [val] : []);
|
|
74
|
+
const existingSet = new Set(arr.map((item) => (typeof item === 'object' ? JSON.stringify(item) : item)));
|
|
75
|
+
for (const item of toAdd) {
|
|
76
|
+
const itemKey = typeof item === 'object' ? JSON.stringify(item) : item;
|
|
77
|
+
if (!existingSet.has(itemKey)) {
|
|
78
|
+
existingSet.add(itemKey);
|
|
79
|
+
arr.push(item);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
obj[current] = arr;
|
|
64
83
|
}
|
|
65
|
-
else if (
|
|
84
|
+
else if (method === 'arrayRemove') {
|
|
66
85
|
const arr = Array.isArray(obj[current]) ? obj[current] : [];
|
|
67
|
-
const toRemove =
|
|
68
|
-
|
|
86
|
+
const toRemove = Array.isArray(val) ? val : (val !== undefined ? [val] : []);
|
|
87
|
+
const removeSet = new Set(toRemove.map((item) => (typeof item === 'object' ? JSON.stringify(item) : item)));
|
|
88
|
+
obj[current] = arr.filter((x) => {
|
|
89
|
+
const itemKey = typeof x === 'object' ? JSON.stringify(x) : x;
|
|
90
|
+
return !removeSet.has(itemKey);
|
|
91
|
+
});
|
|
69
92
|
}
|
|
70
|
-
else if (
|
|
93
|
+
else if (method === 'deleteField') {
|
|
71
94
|
delete obj[current];
|
|
72
95
|
}
|
|
73
96
|
}
|
|
74
|
-
else if (value &&
|
|
75
|
-
|
|
97
|
+
else if (value &&
|
|
98
|
+
typeof value === 'object' &&
|
|
99
|
+
!Array.isArray(value) &&
|
|
100
|
+
typeof value.isEqual !== 'function' &&
|
|
101
|
+
!(value instanceof Timestamp_1.Timestamp)) {
|
|
102
|
+
if (!obj[current] || typeof obj[current] !== 'object' || Array.isArray(obj[current])) {
|
|
76
103
|
obj[current] = {};
|
|
104
|
+
}
|
|
77
105
|
for (const [k, v] of Object.entries(value)) {
|
|
78
106
|
processNode(obj[current], [k], v);
|
|
79
107
|
}
|
|
@@ -83,8 +111,13 @@ function applyDataTransforms(existingData, updateData) {
|
|
|
83
111
|
}
|
|
84
112
|
}
|
|
85
113
|
else {
|
|
86
|
-
if (!obj[current] || typeof obj[current] !== 'object')
|
|
114
|
+
if (!obj[current] || typeof obj[current] !== 'object' || Array.isArray(obj[current])) {
|
|
115
|
+
const isDelete = FieldValue_1.FieldValue.isFieldValue(value) ||
|
|
116
|
+
(value && typeof value === 'object' && value.method === 'deleteField');
|
|
117
|
+
if (isDelete)
|
|
118
|
+
return;
|
|
87
119
|
obj[current] = {};
|
|
120
|
+
}
|
|
88
121
|
processNode(obj[current], keyPath.slice(1), value);
|
|
89
122
|
}
|
|
90
123
|
};
|
package/package.json
CHANGED