nexabase-console 2.1.2 → 2.1.3
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 +3 -0
- package/dist/app/NexaApp.js +38 -11
- package/dist/firestore/CollectionReference.d.ts +2 -2
- package/dist/firestore/CollectionReference.js +41 -2
- package/dist/firestore/DocumentReference.d.ts +1 -1
- package/dist/firestore/DocumentReference.js +41 -11
- package/dist/firestore/FieldPath.d.ts +2 -1
- package/dist/firestore/FieldPath.js +31 -4
- package/dist/firestore/FieldValue.d.ts +8 -4
- package/dist/firestore/FieldValue.js +59 -7
- package/dist/firestore/Firestore.js +16 -4
- package/dist/firestore/GeoPoint.d.ts +1 -0
- package/dist/firestore/GeoPoint.js +13 -6
- package/dist/firestore/Query.d.ts +5 -0
- package/dist/firestore/Query.js +190 -56
- package/dist/firestore/QueryUtils.d.ts +19 -2
- package/dist/firestore/QueryUtils.js +296 -58
- package/dist/firestore/Snapshot.js +62 -17
- package/dist/firestore/SnapshotManager.js +27 -12
- package/dist/firestore/Timestamp.d.ts +1 -0
- package/dist/firestore/Timestamp.js +28 -2
- package/dist/firestore/batch.js +89 -36
- package/dist/firestore/pathValidation.d.ts +17 -0
- package/dist/firestore/pathValidation.js +61 -0
- package/dist/firestore/queryConstraints.d.ts +4 -4
- package/dist/firestore/queryConstraints.js +69 -8
- package/dist/firestore/transaction.js +72 -18
- package/dist/firestore/writes.d.ts +1 -1
- package/dist/firestore/writes.js +32 -14
- package/dist/transport/WebSocketClient.d.ts +341 -9
- package/dist/transport/WebSocketClient.js +1895 -131
- package/dist/transport/WebSocketClient.test.d.ts +1 -0
- package/dist/transport/WebSocketClient.test.js +454 -0
- package/dist/types/index.d.ts +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_test_1 = require("node:test");
|
|
7
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
8
|
+
const WebSocketClient_1 = require("./WebSocketClient");
|
|
9
|
+
// Mock Socket.IO Socket implementation
|
|
10
|
+
class MockSocket {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.id = `mock_socket_${Math.random().toString(36).substring(2, 7)}`;
|
|
13
|
+
this.connected = false;
|
|
14
|
+
this.auth = null;
|
|
15
|
+
this.eventHandlers = new Map();
|
|
16
|
+
this.emittedEvents = [];
|
|
17
|
+
}
|
|
18
|
+
on(event, handler) {
|
|
19
|
+
if (!this.eventHandlers.has(event)) {
|
|
20
|
+
this.eventHandlers.set(event, new Set());
|
|
21
|
+
}
|
|
22
|
+
this.eventHandlers.get(event).add(handler);
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
removeAllListeners() {
|
|
26
|
+
this.eventHandlers.clear();
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
emit(event, ...args) {
|
|
30
|
+
this.emittedEvents.push({ event, args });
|
|
31
|
+
// Check if last arg is an acknowledgement callback
|
|
32
|
+
const lastArg = args[args.length - 1];
|
|
33
|
+
if (typeof lastArg === 'function') {
|
|
34
|
+
// simulate default server ack
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
lastArg({ success: true, event });
|
|
37
|
+
}, 5);
|
|
38
|
+
}
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
trigger(event, ...args) {
|
|
42
|
+
const handlers = this.eventHandlers.get(event);
|
|
43
|
+
if (handlers) {
|
|
44
|
+
handlers.forEach((h) => h(...args));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
connect() {
|
|
48
|
+
this.connected = true;
|
|
49
|
+
this.trigger('connect');
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
disconnect() {
|
|
53
|
+
this.connected = false;
|
|
54
|
+
this.trigger('disconnect', 'io client disconnect');
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Mock BroadcastChannel implementation
|
|
59
|
+
class MockBroadcastChannel {
|
|
60
|
+
constructor(name) {
|
|
61
|
+
this.closed = false;
|
|
62
|
+
this.onmessage = null;
|
|
63
|
+
this.postedMessages = [];
|
|
64
|
+
this.shouldFailPost = false;
|
|
65
|
+
this.name = name;
|
|
66
|
+
if (!MockBroadcastChannel.channels.has(name)) {
|
|
67
|
+
MockBroadcastChannel.channels.set(name, new Set());
|
|
68
|
+
}
|
|
69
|
+
MockBroadcastChannel.channels.get(name).add(this);
|
|
70
|
+
}
|
|
71
|
+
postMessage(message) {
|
|
72
|
+
if (this.closed)
|
|
73
|
+
throw new Error('BroadcastChannel is closed');
|
|
74
|
+
if (this.shouldFailPost)
|
|
75
|
+
throw new Error('Simulated BroadcastChannel failure');
|
|
76
|
+
this.postedMessages.push(message);
|
|
77
|
+
const peers = MockBroadcastChannel.channels.get(this.name);
|
|
78
|
+
if (peers) {
|
|
79
|
+
peers.forEach((peer) => {
|
|
80
|
+
if (peer !== this && !peer.closed && typeof peer.onmessage === 'function') {
|
|
81
|
+
queueMicrotask(() => {
|
|
82
|
+
if (!peer.closed && typeof peer.onmessage === 'function') {
|
|
83
|
+
peer.onmessage(new MessageEvent('message', { data: message }));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
close() {
|
|
91
|
+
this.closed = true;
|
|
92
|
+
const peers = MockBroadcastChannel.channels.get(this.name);
|
|
93
|
+
if (peers) {
|
|
94
|
+
peers.delete(this);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
static reset() {
|
|
98
|
+
MockBroadcastChannel.channels.clear();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
MockBroadcastChannel.channels = new Map();
|
|
102
|
+
// Mock Web Lock Manager
|
|
103
|
+
class MockLockManager {
|
|
104
|
+
constructor() {
|
|
105
|
+
this.activeLocks = new Map();
|
|
106
|
+
this.queues = new Map();
|
|
107
|
+
}
|
|
108
|
+
request(name, optionsOrCallback, callback) {
|
|
109
|
+
const cb = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback;
|
|
110
|
+
const signal = optionsOrCallback?.signal;
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
if (signal?.aborted) {
|
|
113
|
+
return reject(new DOMException('The request was aborted', 'AbortError'));
|
|
114
|
+
}
|
|
115
|
+
const executeLock = async () => {
|
|
116
|
+
if (signal?.aborted) {
|
|
117
|
+
const next = this.queues.get(name)?.shift();
|
|
118
|
+
if (next)
|
|
119
|
+
next();
|
|
120
|
+
return reject(new DOMException('The request was aborted', 'AbortError'));
|
|
121
|
+
}
|
|
122
|
+
let lockReleased = false;
|
|
123
|
+
const release = () => {
|
|
124
|
+
if (lockReleased)
|
|
125
|
+
return;
|
|
126
|
+
lockReleased = true;
|
|
127
|
+
this.activeLocks.delete(name);
|
|
128
|
+
const next = this.queues.get(name)?.shift();
|
|
129
|
+
if (next) {
|
|
130
|
+
next();
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
this.activeLocks.set(name, { release });
|
|
134
|
+
if (signal) {
|
|
135
|
+
signal.addEventListener('abort', () => {
|
|
136
|
+
release();
|
|
137
|
+
reject(new DOMException('The request was aborted', 'AbortError'));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const result = await cb({ name });
|
|
142
|
+
release();
|
|
143
|
+
resolve(result);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
release();
|
|
147
|
+
reject(err);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
if (!this.activeLocks.has(name)) {
|
|
151
|
+
executeLock();
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
if (!this.queues.has(name)) {
|
|
155
|
+
this.queues.set(name, []);
|
|
156
|
+
}
|
|
157
|
+
this.queues.get(name).push(executeLock);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
reset() {
|
|
162
|
+
this.activeLocks.clear();
|
|
163
|
+
this.queues.clear();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
(0, node_test_1.describe)('WebSocketClient Production Hardening Test Suite', () => {
|
|
167
|
+
let mockLocks;
|
|
168
|
+
let activeSockets;
|
|
169
|
+
(0, node_test_1.beforeEach)(() => {
|
|
170
|
+
MockBroadcastChannel.reset();
|
|
171
|
+
mockLocks = new MockLockManager();
|
|
172
|
+
activeSockets = [];
|
|
173
|
+
// Polyfill window & global environment for Node.js test runner
|
|
174
|
+
globalThis.window = {
|
|
175
|
+
location: { origin: 'http://localhost:3000' },
|
|
176
|
+
addEventListener: () => { },
|
|
177
|
+
removeEventListener: () => { },
|
|
178
|
+
};
|
|
179
|
+
globalThis.document = {
|
|
180
|
+
visibilityState: 'visible',
|
|
181
|
+
addEventListener: () => { },
|
|
182
|
+
removeEventListener: () => { },
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
(0, node_test_1.afterEach)(() => {
|
|
186
|
+
MockBroadcastChannel.reset();
|
|
187
|
+
mockLocks.reset();
|
|
188
|
+
});
|
|
189
|
+
const createTestClient = (projectId = 'test-proj', serverUrl = 'http://localhost:3000', options = {}) => {
|
|
190
|
+
const socket = new MockSocket();
|
|
191
|
+
activeSockets.push(socket);
|
|
192
|
+
const client = new WebSocketClient_1.WebSocketClient(projectId, serverUrl, {
|
|
193
|
+
socketFactory: (url, opts) => {
|
|
194
|
+
socket.auth = opts?.auth || null;
|
|
195
|
+
return socket;
|
|
196
|
+
},
|
|
197
|
+
broadcastChannelFactory: (name) => new MockBroadcastChannel(name),
|
|
198
|
+
locks: mockLocks,
|
|
199
|
+
followerTtlMs: 1000,
|
|
200
|
+
heartbeatIntervalMs: 200,
|
|
201
|
+
pruneIntervalMs: 500,
|
|
202
|
+
leaderLeaseIntervalMs: 500,
|
|
203
|
+
...options,
|
|
204
|
+
});
|
|
205
|
+
return { client, socket };
|
|
206
|
+
};
|
|
207
|
+
(0, node_test_1.describe)('1. Argument Validation & Construction', () => {
|
|
208
|
+
(0, node_test_1.it)('throws invalid-argument error for empty projectId', () => {
|
|
209
|
+
strict_1.default.throws(() => new WebSocketClient_1.WebSocketClient('', 'http://localhost:3000'), {
|
|
210
|
+
name: 'NexaWebSocketError',
|
|
211
|
+
code: 'invalid-argument',
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
(0, node_test_1.it)('throws invalid-argument error for empty serverUrl', () => {
|
|
215
|
+
strict_1.default.throws(() => new WebSocketClient_1.WebSocketClient('test-proj', ''), {
|
|
216
|
+
name: 'NexaWebSocketError',
|
|
217
|
+
code: 'invalid-argument',
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
(0, node_test_1.it)('derives stable coordination keys without exposing tokens', () => {
|
|
221
|
+
const key1 = WebSocketClient_1.WebSocketClient.deriveCoordinationKey('proj', 'http://localhost:3000', 'scopeA');
|
|
222
|
+
const key2 = WebSocketClient_1.WebSocketClient.deriveCoordinationKey('proj', 'http://localhost:3000', 'scopeA');
|
|
223
|
+
const key3 = WebSocketClient_1.WebSocketClient.deriveCoordinationKey('proj', 'http://localhost:3000', 'scopeB');
|
|
224
|
+
strict_1.default.equal(key1, key2);
|
|
225
|
+
strict_1.default.notEqual(key1, key3);
|
|
226
|
+
strict_1.default.equal(key1.length, 16);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
(0, node_test_1.describe)('2. Tri-state Path Extraction & Strict Segment Matching', () => {
|
|
230
|
+
(0, node_test_1.it)('validates segment-aware path matching correctly', () => {
|
|
231
|
+
strict_1.default.equal(WebSocketClient_1.WebSocketClient.isPathMatch('users/12', 'users/12'), true);
|
|
232
|
+
strict_1.default.equal(WebSocketClient_1.WebSocketClient.isPathMatch('users/12/profile', 'users/12'), true);
|
|
233
|
+
strict_1.default.equal(WebSocketClient_1.WebSocketClient.isPathMatch('users/12', 'users/12/profile'), true);
|
|
234
|
+
// Boundary check: users/12 MUST NOT match users/123 or users/1
|
|
235
|
+
strict_1.default.equal(WebSocketClient_1.WebSocketClient.isPathMatch('users/123', 'users/12'), false);
|
|
236
|
+
strict_1.default.equal(WebSocketClient_1.WebSocketClient.isPathMatch('users/1', 'users/12'), false);
|
|
237
|
+
strict_1.default.equal(WebSocketClient_1.WebSocketClient.isPathMatch('posts/10', 'posts/1'), false);
|
|
238
|
+
});
|
|
239
|
+
(0, node_test_1.it)('extracts tri-state status (none, valid, invalid)', () => {
|
|
240
|
+
strict_1.default.deepEqual(WebSocketClient_1.WebSocketClient.extractPayloadPathTriState({}), { status: 'none' });
|
|
241
|
+
strict_1.default.deepEqual(WebSocketClient_1.WebSocketClient.extractPayloadPathTriState({ path: 'users/alice' }), {
|
|
242
|
+
status: 'valid',
|
|
243
|
+
path: 'users/alice',
|
|
244
|
+
});
|
|
245
|
+
strict_1.default.deepEqual(WebSocketClient_1.WebSocketClient.extractPayloadPathTriState({ docPath: '/posts/123/' }), {
|
|
246
|
+
status: 'valid',
|
|
247
|
+
path: 'posts/123',
|
|
248
|
+
});
|
|
249
|
+
strict_1.default.equal(WebSocketClient_1.WebSocketClient.extractPayloadPathTriState({ path: 'users//alice' }).status, 'invalid');
|
|
250
|
+
strict_1.default.equal(WebSocketClient_1.WebSocketClient.extractPayloadPathTriState({ path: '' }).status, 'invalid');
|
|
251
|
+
});
|
|
252
|
+
(0, node_test_1.it)('quarantines malformed paths from becoming global events in handleSocketDataEvent', async () => {
|
|
253
|
+
const { client, socket } = createTestClient();
|
|
254
|
+
client.ensureConnected();
|
|
255
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
256
|
+
socket.connect();
|
|
257
|
+
let receivedCount = 0;
|
|
258
|
+
client.addListener('data_changed', () => {
|
|
259
|
+
receivedCount++;
|
|
260
|
+
});
|
|
261
|
+
// Emit malformed path
|
|
262
|
+
socket.trigger('data_changed', { path: 'users//invalid' });
|
|
263
|
+
strict_1.default.equal(receivedCount, 0);
|
|
264
|
+
// Emit valid untargeted without explicit isGlobal flag -> ignored
|
|
265
|
+
socket.trigger('data_changed', { value: 123 });
|
|
266
|
+
strict_1.default.equal(receivedCount, 0);
|
|
267
|
+
// Emit valid with proper path matching subscription
|
|
268
|
+
client.subscribe('users/alice');
|
|
269
|
+
socket.trigger('data_changed', { path: 'users/alice', value: 456 });
|
|
270
|
+
strict_1.default.equal(receivedCount, 1);
|
|
271
|
+
client.close();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
(0, node_test_1.describe)('3. Request Counter Invariants in send()', () => {
|
|
275
|
+
(0, node_test_1.it)('increments and decrements counter safely on successful execution', async () => {
|
|
276
|
+
const { client, socket } = createTestClient();
|
|
277
|
+
client.ensureConnected();
|
|
278
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
279
|
+
socket.connect();
|
|
280
|
+
const sendPromise = client.send('save_data', { foo: 'bar' });
|
|
281
|
+
strict_1.default.equal(client.activeLocalSendsCount, 1);
|
|
282
|
+
const res = await sendPromise;
|
|
283
|
+
strict_1.default.deepEqual(res, { success: true, event: 'save_data' });
|
|
284
|
+
strict_1.default.equal(client.activeLocalSendsCount, 0);
|
|
285
|
+
client.close();
|
|
286
|
+
});
|
|
287
|
+
(0, node_test_1.it)('decrements counter on timeout without leaking state', async () => {
|
|
288
|
+
const { client, socket } = createTestClient();
|
|
289
|
+
client.ensureConnected();
|
|
290
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
291
|
+
// Socket connected but no ack sent
|
|
292
|
+
socket.emit = function (event, ...args) {
|
|
293
|
+
this.emittedEvents.push({ event, args });
|
|
294
|
+
return this; // no ack
|
|
295
|
+
};
|
|
296
|
+
socket.connect();
|
|
297
|
+
await strict_1.default.rejects(async () => {
|
|
298
|
+
await client.send('query_data', { q: 1 }, 50);
|
|
299
|
+
}, {
|
|
300
|
+
name: 'NexaWebSocketError',
|
|
301
|
+
code: 'timeout',
|
|
302
|
+
});
|
|
303
|
+
strict_1.default.equal(client.activeLocalSendsCount, 0);
|
|
304
|
+
client.close();
|
|
305
|
+
});
|
|
306
|
+
(0, node_test_1.it)('rejects reserved send events with invalid-argument', async () => {
|
|
307
|
+
const { client } = createTestClient();
|
|
308
|
+
await strict_1.default.rejects(async () => {
|
|
309
|
+
await client.send('connect', {});
|
|
310
|
+
}, {
|
|
311
|
+
name: 'NexaWebSocketError',
|
|
312
|
+
code: 'invalid-argument',
|
|
313
|
+
});
|
|
314
|
+
strict_1.default.equal(client.activeLocalSendsCount, 0);
|
|
315
|
+
client.close();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
(0, node_test_1.describe)('4. Multi-tab Leader/Follower Coordination & Term Fencing', () => {
|
|
319
|
+
(0, node_test_1.it)('elects first tab as leader and second tab as follower', async () => {
|
|
320
|
+
const tabA = createTestClient('shared-proj');
|
|
321
|
+
const tabB = createTestClient('shared-proj');
|
|
322
|
+
tabA.client.ensureConnected();
|
|
323
|
+
tabB.client.ensureConnected();
|
|
324
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
325
|
+
strict_1.default.equal(tabA.client.isLeader, true);
|
|
326
|
+
strict_1.default.equal(tabA.client.state, 'leader');
|
|
327
|
+
strict_1.default.equal(tabB.client.isLeader, false);
|
|
328
|
+
strict_1.default.equal(tabB.client.state, 'follower');
|
|
329
|
+
strict_1.default.equal(tabB.client.currentLeaderId, tabA.client.tabId);
|
|
330
|
+
strict_1.default.equal(tabB.client.currentElectionId, tabA.client.electionId);
|
|
331
|
+
tabA.client.close();
|
|
332
|
+
tabB.client.close();
|
|
333
|
+
});
|
|
334
|
+
(0, node_test_1.it)('forwards follower send to leader and receives response', async () => {
|
|
335
|
+
const tabA = createTestClient('shared-proj');
|
|
336
|
+
const tabB = createTestClient('shared-proj');
|
|
337
|
+
tabA.client.ensureConnected();
|
|
338
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
339
|
+
tabA.socket.connect();
|
|
340
|
+
tabB.client.ensureConnected();
|
|
341
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
342
|
+
const res = await tabB.client.send('fetch_doc', { id: 'doc123' }, 2000);
|
|
343
|
+
strict_1.default.deepEqual(res, { success: true, event: 'fetch_doc' });
|
|
344
|
+
tabA.client.close();
|
|
345
|
+
tabB.client.close();
|
|
346
|
+
});
|
|
347
|
+
(0, node_test_1.it)('deduplicates duplicate follower send requests at leader', async () => {
|
|
348
|
+
const tabA = createTestClient('shared-proj');
|
|
349
|
+
const tabB = createTestClient('shared-proj');
|
|
350
|
+
tabA.client.ensureConnected();
|
|
351
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
352
|
+
tabA.socket.connect();
|
|
353
|
+
tabB.client.ensureConnected();
|
|
354
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
355
|
+
let socketEmitCount = 0;
|
|
356
|
+
const originalEmit = tabA.socket.emit.bind(tabA.socket);
|
|
357
|
+
tabA.socket.emit = function (event, ...args) {
|
|
358
|
+
socketEmitCount++;
|
|
359
|
+
return originalEmit(event, ...args);
|
|
360
|
+
};
|
|
361
|
+
const res1 = await tabB.client.send('fetch_user', { uid: 'u1' });
|
|
362
|
+
strict_1.default.ok(res1);
|
|
363
|
+
strict_1.default.equal(socketEmitCount, 1);
|
|
364
|
+
tabA.client.close();
|
|
365
|
+
tabB.client.close();
|
|
366
|
+
});
|
|
367
|
+
(0, node_test_1.it)('returns ambiguous-outcome error when mutating send is disrupted during election change', async () => {
|
|
368
|
+
const tabA = createTestClient('shared-proj');
|
|
369
|
+
const tabB = createTestClient('shared-proj');
|
|
370
|
+
tabA.client.ensureConnected();
|
|
371
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
372
|
+
tabA.socket.connect();
|
|
373
|
+
tabB.client.ensureConnected();
|
|
374
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
375
|
+
// Override tabA.socket.emit so it does not ack immediately
|
|
376
|
+
tabA.socket.emit = function (event, ...args) {
|
|
377
|
+
this.emittedEvents.push({ event, args });
|
|
378
|
+
return this;
|
|
379
|
+
};
|
|
380
|
+
const mutatingPromise = tabB.client.send('write_document', { data: 'test' }, 2000);
|
|
381
|
+
// Wait for follower message to reach leader
|
|
382
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
383
|
+
// Close leader abruptly
|
|
384
|
+
tabA.client.close();
|
|
385
|
+
await strict_1.default.rejects(mutatingPromise, (err) => {
|
|
386
|
+
strict_1.default.equal(err.name, 'NexaWebSocketError');
|
|
387
|
+
strict_1.default.equal(err.isAmbiguous, true);
|
|
388
|
+
return true;
|
|
389
|
+
});
|
|
390
|
+
tabB.client.close();
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
(0, node_test_1.describe)('5. Independent Coordination Mode Fallback', () => {
|
|
394
|
+
(0, node_test_1.it)('runs in independent mode when coordinationMode option is set to independent', async () => {
|
|
395
|
+
const { client, socket } = createTestClient('proj', 'http://localhost:3000', {
|
|
396
|
+
coordinationMode: 'independent',
|
|
397
|
+
});
|
|
398
|
+
strict_1.default.equal(client.isIndependent, true);
|
|
399
|
+
client.ensureConnected();
|
|
400
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
401
|
+
strict_1.default.equal(client.state, 'leader');
|
|
402
|
+
socket.connect();
|
|
403
|
+
strict_1.default.equal(client.isConnected, true);
|
|
404
|
+
client.close();
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
(0, node_test_1.describe)('6. Dynamic Auth Refresh', () => {
|
|
408
|
+
(0, node_test_1.it)('retrieves fresh auth payload on socket startup and updateAuth', async () => {
|
|
409
|
+
let currentToken = 'token_v1';
|
|
410
|
+
const { client, socket } = createTestClient('proj', 'http://localhost:3000', {
|
|
411
|
+
getAuthPayload: async () => ({ token: currentToken }),
|
|
412
|
+
});
|
|
413
|
+
client.ensureConnected();
|
|
414
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
415
|
+
strict_1.default.deepEqual(socket.auth, { token: 'token_v1' });
|
|
416
|
+
client.updateAuth({ token: 'token_v2' });
|
|
417
|
+
strict_1.default.deepEqual(socket.auth, { token: 'token_v2' });
|
|
418
|
+
client.close();
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
(0, node_test_1.describe)('7. Defensive Listener Dispatch & Reference Counting', () => {
|
|
422
|
+
(0, node_test_1.it)('allows listeners to unsubscribe safely during dispatch without skipping peers', async () => {
|
|
423
|
+
const { client, socket } = createTestClient();
|
|
424
|
+
client.ensureConnected();
|
|
425
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
426
|
+
socket.connect();
|
|
427
|
+
let order = [];
|
|
428
|
+
let unsubA;
|
|
429
|
+
unsubA = client.addListener('custom_event', () => {
|
|
430
|
+
order.push('A');
|
|
431
|
+
unsubA(); // unregister self inside callback
|
|
432
|
+
});
|
|
433
|
+
client.addListener('custom_event', () => {
|
|
434
|
+
order.push('B');
|
|
435
|
+
});
|
|
436
|
+
client.dispatch('custom_event', { foo: 'bar' });
|
|
437
|
+
strict_1.default.deepEqual(order, ['A', 'B']);
|
|
438
|
+
// Second dispatch: only B should fire
|
|
439
|
+
order = [];
|
|
440
|
+
client.dispatch('custom_event', { foo: 'bar' });
|
|
441
|
+
strict_1.default.deepEqual(order, ['B']);
|
|
442
|
+
client.close();
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
(0, node_test_1.describe)('8. Safe and Idempotent close() Teardown', () => {
|
|
446
|
+
(0, node_test_1.it)('can be called multiple times safely without throwing', () => {
|
|
447
|
+
const { client } = createTestClient();
|
|
448
|
+
client.ensureConnected();
|
|
449
|
+
strict_1.default.doesNotThrow(() => client.close());
|
|
450
|
+
strict_1.default.doesNotThrow(() => client.close());
|
|
451
|
+
strict_1.default.equal(client.state, 'disposed');
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
});
|
package/dist/types/index.d.ts
CHANGED
package/package.json
CHANGED