Skip to content

Commit 85004da

Browse files
authored
Merge pull request #679 from Xaxxoo/feature/597-websocket-data-bus
feat(mobile): implement highly-concurrent WebSocket data bus
2 parents 2419b2f + 17d9473 commit 85004da

2 files changed

Lines changed: 678 additions & 0 deletions

File tree

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import {
2+
RingBuffer,
3+
HermesBridge,
4+
UICommitBatcher,
5+
parseFrame,
6+
ConcurrentWebSocketDataBus,
7+
FRAME_BUDGET_MS,
8+
RING_BUFFER_CAPACITY,
9+
MAX_MESSAGES_PER_FLUSH,
10+
BusMessage,
11+
} from '../../src/services/ConcurrentWebSocketDataBus';
12+
13+
// ─── parseFrame ────────────────────────────────────────────────────────────────
14+
15+
describe('parseFrame', () => {
16+
it('parses a valid message', () => {
17+
const raw = JSON.stringify({ type: 'feed.update', payload: { id: 1 }, timestamp: 1000, id: 'abc' });
18+
const result = parseFrame(raw);
19+
expect(result.ok).toBe(true);
20+
expect(result.message?.type).toBe('feed.update');
21+
});
22+
23+
it('returns error for malformed JSON', () => {
24+
const result = parseFrame('{not json}');
25+
expect(result.ok).toBe(false);
26+
expect(result.error).toBeTruthy();
27+
});
28+
29+
it('returns error when type field is missing', () => {
30+
const result = parseFrame(JSON.stringify({ payload: 'x', timestamp: 0 }));
31+
expect(result.ok).toBe(false);
32+
});
33+
34+
it('returns error for null JSON', () => {
35+
const result = parseFrame('null');
36+
expect(result.ok).toBe(false);
37+
});
38+
39+
it('returns error for non-object JSON', () => {
40+
const result = parseFrame('"string"');
41+
expect(result.ok).toBe(false);
42+
});
43+
});
44+
45+
// ─── RingBuffer ────────────────────────────────────────────────────────────────
46+
47+
describe('RingBuffer', () => {
48+
it('starts empty', () => {
49+
const rb = new RingBuffer<number>(4);
50+
expect(rb.size).toBe(0);
51+
expect(rb.isEmpty()).toBe(true);
52+
});
53+
54+
it('accepts items up to capacity', () => {
55+
const rb = new RingBuffer<number>(3);
56+
rb.push(1);
57+
rb.push(2);
58+
rb.push(3);
59+
expect(rb.size).toBe(3);
60+
});
61+
62+
it('evicts oldest item when capacity is exceeded', () => {
63+
const rb = new RingBuffer<number>(3);
64+
rb.push(1);
65+
rb.push(2);
66+
rb.push(3);
67+
rb.push(4); // evicts 1
68+
expect(rb.size).toBe(3);
69+
expect(rb.shift()).toBe(2);
70+
});
71+
72+
it('shift returns undefined on empty buffer', () => {
73+
const rb = new RingBuffer<number>(4);
74+
expect(rb.shift()).toBeUndefined();
75+
});
76+
77+
it('preserves FIFO order', () => {
78+
const rb = new RingBuffer<number>(5);
79+
[10, 20, 30].forEach((v) => rb.push(v));
80+
expect(rb.shift()).toBe(10);
81+
expect(rb.shift()).toBe(20);
82+
expect(rb.shift()).toBe(30);
83+
});
84+
85+
it('clear resets all counters', () => {
86+
const rb = new RingBuffer<number>(5);
87+
rb.push(1);
88+
rb.push(2);
89+
rb.clear();
90+
expect(rb.size).toBe(0);
91+
expect(rb.isEmpty()).toBe(true);
92+
});
93+
94+
it('wraps around correctly after interleaved push/shift', () => {
95+
const rb = new RingBuffer<number>(3);
96+
rb.push(1);
97+
rb.push(2);
98+
rb.shift(); // removes 1
99+
rb.push(3);
100+
rb.push(4);
101+
expect(rb.size).toBe(3);
102+
expect(rb.shift()).toBe(2);
103+
});
104+
});
105+
106+
// ─── HermesBridge ─────────────────────────────────────────────────────────────
107+
108+
describe('HermesBridge', () => {
109+
it('executes scheduled tasks asynchronously', async () => {
110+
const bridge = new HermesBridge();
111+
const results: number[] = [];
112+
113+
bridge.scheduleTask(() => results.push(1));
114+
bridge.scheduleTask(() => results.push(2));
115+
116+
expect(results).toHaveLength(0); // Not yet executed
117+
118+
await Promise.resolve(); // Flush microtask queue
119+
await Promise.resolve(); // Allow drain loop to complete
120+
121+
expect(results).toEqual([1, 2]);
122+
});
123+
124+
it('reports pendingCount before drain', () => {
125+
const bridge = new HermesBridge();
126+
bridge.scheduleTask(() => {});
127+
bridge.scheduleTask(() => {});
128+
expect(bridge.pendingCount()).toBe(2);
129+
});
130+
131+
it('swallows task errors without crashing', async () => {
132+
const bridge = new HermesBridge();
133+
const after: string[] = [];
134+
bridge.scheduleTask(() => { throw new Error('boom'); });
135+
bridge.scheduleTask(() => after.push('ok'));
136+
await Promise.resolve();
137+
await Promise.resolve();
138+
expect(after).toEqual(['ok']);
139+
});
140+
});
141+
142+
// ─── UICommitBatcher ──────────────────────────────────────────────────────────
143+
144+
describe('UICommitBatcher', () => {
145+
beforeEach(() => jest.useFakeTimers());
146+
afterEach(() => jest.useRealTimers());
147+
148+
it('delivers messages to typed handlers on flush', () => {
149+
const batcher = new UICommitBatcher(FRAME_BUDGET_MS);
150+
const received: BusMessage[] = [];
151+
batcher.on('feed.update', (m) => received.push(m));
152+
batcher.start();
153+
154+
const msg: BusMessage = { type: 'feed.update', payload: {}, timestamp: 0, id: '1' };
155+
batcher.enqueue(msg);
156+
157+
jest.advanceTimersByTime(FRAME_BUDGET_MS);
158+
expect(received).toHaveLength(1);
159+
expect(received[0].type).toBe('feed.update');
160+
batcher.stop();
161+
});
162+
163+
it('delivers messages to wildcard handlers', () => {
164+
const batcher = new UICommitBatcher(FRAME_BUDGET_MS);
165+
const received: BusMessage[] = [];
166+
batcher.on('*', (m) => received.push(m));
167+
batcher.start();
168+
169+
batcher.enqueue({ type: 'a', payload: null, timestamp: 0, id: '1' });
170+
batcher.enqueue({ type: 'b', payload: null, timestamp: 0, id: '2' });
171+
172+
jest.advanceTimersByTime(FRAME_BUDGET_MS);
173+
expect(received).toHaveLength(2);
174+
batcher.stop();
175+
});
176+
177+
it('unsubscribe removes handler', () => {
178+
const batcher = new UICommitBatcher(FRAME_BUDGET_MS);
179+
const received: BusMessage[] = [];
180+
const unsub = batcher.on('evt', (m) => received.push(m));
181+
batcher.start();
182+
183+
unsub();
184+
batcher.enqueue({ type: 'evt', payload: null, timestamp: 0, id: '1' });
185+
jest.advanceTimersByTime(FRAME_BUDGET_MS);
186+
expect(received).toHaveLength(0);
187+
batcher.stop();
188+
});
189+
190+
it('batches up to MAX_MESSAGES_PER_FLUSH per tick', () => {
191+
const batcher = new UICommitBatcher(FRAME_BUDGET_MS);
192+
const received: BusMessage[] = [];
193+
batcher.on('*', (m) => received.push(m));
194+
batcher.start();
195+
196+
for (let i = 0; i < MAX_MESSAGES_PER_FLUSH + 50; i++) {
197+
batcher.enqueue({ type: 'x', payload: i, timestamp: 0, id: String(i) });
198+
}
199+
200+
jest.advanceTimersByTime(FRAME_BUDGET_MS);
201+
expect(received.length).toBeLessThanOrEqual(MAX_MESSAGES_PER_FLUSH);
202+
// Remaining messages still pending
203+
expect(batcher.pendingCount).toBeGreaterThan(0);
204+
batcher.stop();
205+
});
206+
207+
it('stop prevents further deliveries', () => {
208+
const batcher = new UICommitBatcher(FRAME_BUDGET_MS);
209+
const received: BusMessage[] = [];
210+
batcher.on('*', (m) => received.push(m));
211+
batcher.start();
212+
batcher.stop();
213+
214+
batcher.enqueue({ type: 'x', payload: null, timestamp: 0, id: '1' });
215+
jest.advanceTimersByTime(FRAME_BUDGET_MS * 5);
216+
expect(received).toHaveLength(0);
217+
});
218+
219+
it('flushedCount increments after each flush', () => {
220+
const batcher = new UICommitBatcher(FRAME_BUDGET_MS);
221+
batcher.start();
222+
batcher.enqueue({ type: 'x', payload: null, timestamp: 0, id: '1' });
223+
batcher.enqueue({ type: 'x', payload: null, timestamp: 0, id: '2' });
224+
jest.advanceTimersByTime(FRAME_BUDGET_MS);
225+
expect(batcher.flushedCount).toBe(2);
226+
batcher.stop();
227+
});
228+
});
229+
230+
// ─── ConcurrentWebSocketDataBus ───────────────────────────────────────────────
231+
232+
describe('ConcurrentWebSocketDataBus', () => {
233+
beforeEach(() => jest.useFakeTimers());
234+
afterEach(() => jest.useRealTimers());
235+
236+
it('exposes correct default constants', () => {
237+
expect(FRAME_BUDGET_MS).toBe(16);
238+
expect(RING_BUFFER_CAPACITY).toBe(512);
239+
expect(MAX_MESSAGES_PER_FLUSH).toBe(256);
240+
});
241+
242+
it('injectFrame parses and enqueues message', async () => {
243+
const bus = new ConcurrentWebSocketDataBus('ws://localhost:9999');
244+
const received: BusMessage[] = [];
245+
bus.on('feed.update', (m) => received.push(m));
246+
247+
// Start the batcher so flush timers run
248+
// We cannot call connect() without a real WS server, so we tap injectFrame.
249+
const raw = JSON.stringify({ type: 'feed.update', payload: { id: 42 }, timestamp: 1, id: 'x1' });
250+
bus.injectFrame(raw);
251+
252+
// Drain Hermes bridge microtasks
253+
await Promise.resolve();
254+
await Promise.resolve();
255+
256+
jest.advanceTimersByTime(FRAME_BUDGET_MS);
257+
// The batcher isn't started (no connect), so messages remain pending.
258+
// Verify stats instead.
259+
const stats = bus.getStats();
260+
expect(stats.received).toBe(1);
261+
bus.disconnect();
262+
});
263+
264+
it('injectFrame increments parseErrors for bad JSON', async () => {
265+
const bus = new ConcurrentWebSocketDataBus('ws://localhost:9999');
266+
bus.injectFrame('{bad}');
267+
await Promise.resolve();
268+
await Promise.resolve();
269+
const stats = bus.getStats();
270+
expect(stats.parseErrors).toBe(1);
271+
bus.disconnect();
272+
});
273+
274+
it('getStats returns correct received count for multiple injections', async () => {
275+
const bus = new ConcurrentWebSocketDataBus('ws://localhost:9999');
276+
const msg = JSON.stringify({ type: 't', payload: null, timestamp: 0, id: 'y' });
277+
bus.injectFrame(msg);
278+
bus.injectFrame(msg);
279+
bus.injectFrame(msg);
280+
await Promise.resolve();
281+
await Promise.resolve();
282+
expect(bus.getStats().received).toBe(3);
283+
bus.disconnect();
284+
});
285+
286+
it('on() returns unsubscribe function', () => {
287+
const bus = new ConcurrentWebSocketDataBus('ws://localhost:9999');
288+
const unsub = bus.on('evt', () => {});
289+
expect(typeof unsub).toBe('function');
290+
bus.disconnect();
291+
});
292+
293+
it('disconnect stops processing', async () => {
294+
const bus = new ConcurrentWebSocketDataBus('ws://localhost:9999');
295+
bus.disconnect();
296+
const before = bus.getStats().received;
297+
const msg = JSON.stringify({ type: 't', payload: null, timestamp: 0, id: 'z' });
298+
bus.injectFrame(msg);
299+
await Promise.resolve();
300+
// Stats still increment because injectFrame is a test helper, but no delivery occurs
301+
expect(bus.getStats().received).toBe(before + 1);
302+
});
303+
});

0 commit comments

Comments
 (0)