@fluxstack/live-client 0.4.0 → 0.5.1
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/index.cjs +31 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +31 -18
- package/dist/index.js.map +1 -1
- package/dist/live-client.browser.global.js +31 -18
- package/dist/live-client.browser.global.js.map +1 -1
- package/package.json +4 -3
- package/src/__tests__/rooms.binary.test.ts +359 -0
- package/src/component.ts +364 -0
- package/src/connection.ts +508 -0
- package/src/index.ts +219 -0
- package/src/persistence.ts +48 -0
- package/src/rooms.ts +539 -0
- package/src/state-validator.ts +121 -0
- package/src/upload.ts +366 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// Tests for client-side binary room frame handling
|
|
2
|
+
//
|
|
3
|
+
// Validates that the RoomManager correctly:
|
|
4
|
+
// - Parses binary room frames (0x02 ROOM_EVENT, 0x03 ROOM_STATE)
|
|
5
|
+
// - Decodes msgpack payloads
|
|
6
|
+
// - Dispatches to event handlers
|
|
7
|
+
// - Merges state updates
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
10
|
+
import { RoomManager } from '../rooms'
|
|
11
|
+
import type { RoomServerMessage } from '../rooms'
|
|
12
|
+
|
|
13
|
+
// ===== Helpers: build binary frames matching server format =====
|
|
14
|
+
|
|
15
|
+
const encoder = new TextEncoder()
|
|
16
|
+
|
|
17
|
+
// Minimal msgpack encoder (matches server output) — only what we need for tests
|
|
18
|
+
function msgpackEncode(value: unknown): Uint8Array {
|
|
19
|
+
const parts: Uint8Array[] = []
|
|
20
|
+
_encode(value, parts)
|
|
21
|
+
let total = 0
|
|
22
|
+
for (const p of parts) total += p.length
|
|
23
|
+
const result = new Uint8Array(total)
|
|
24
|
+
let off = 0
|
|
25
|
+
for (const p of parts) { result.set(p, off); off += p.length }
|
|
26
|
+
return result
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _encode(value: unknown, parts: Uint8Array[]): void {
|
|
30
|
+
if (value === null || value === undefined) { parts.push(new Uint8Array([0xc0])); return }
|
|
31
|
+
if (typeof value === 'boolean') { parts.push(new Uint8Array([value ? 0xc3 : 0xc2])); return }
|
|
32
|
+
if (typeof value === 'number') {
|
|
33
|
+
if (Number.isInteger(value) && value >= 0 && value < 128) {
|
|
34
|
+
parts.push(new Uint8Array([value])); return
|
|
35
|
+
}
|
|
36
|
+
if (Number.isInteger(value) && value >= -32 && value < 0) {
|
|
37
|
+
parts.push(new Uint8Array([value & 0xff])); return
|
|
38
|
+
}
|
|
39
|
+
// float64
|
|
40
|
+
const buf = new Uint8Array(9); buf[0] = 0xcb
|
|
41
|
+
new DataView(buf.buffer).setFloat64(1, value, false)
|
|
42
|
+
parts.push(buf); return
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === 'string') {
|
|
45
|
+
const encoded = encoder.encode(value)
|
|
46
|
+
if (encoded.length < 32) parts.push(new Uint8Array([0xa0 | encoded.length]))
|
|
47
|
+
else { parts.push(new Uint8Array([0xd9, encoded.length])) }
|
|
48
|
+
parts.push(encoded); return
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
if (value.length < 16) parts.push(new Uint8Array([0x90 | value.length]))
|
|
52
|
+
else parts.push(new Uint8Array([0xdc, value.length >> 8, value.length & 0xff]))
|
|
53
|
+
for (const item of value) _encode(item, parts)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
if (typeof value === 'object') {
|
|
57
|
+
const keys = Object.keys(value as Record<string, unknown>)
|
|
58
|
+
if (keys.length < 16) parts.push(new Uint8Array([0x80 | keys.length]))
|
|
59
|
+
else parts.push(new Uint8Array([0xde, keys.length >> 8, keys.length & 0xff]))
|
|
60
|
+
for (const key of keys) { _encode(key, parts); _encode((value as any)[key], parts) }
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
parts.push(new Uint8Array([0xc0]))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Build a binary room frame matching the server's wire format */
|
|
67
|
+
function buildTestFrame(
|
|
68
|
+
frameType: number,
|
|
69
|
+
componentId: string,
|
|
70
|
+
roomId: string,
|
|
71
|
+
event: string,
|
|
72
|
+
data: unknown,
|
|
73
|
+
): Uint8Array {
|
|
74
|
+
const compIdBytes = encoder.encode(componentId)
|
|
75
|
+
const roomIdBytes = encoder.encode(roomId)
|
|
76
|
+
const eventBytes = encoder.encode(event)
|
|
77
|
+
const payload = msgpackEncode(data)
|
|
78
|
+
|
|
79
|
+
const totalLen = 1 + 1 + compIdBytes.length + 1 + roomIdBytes.length + 2 + eventBytes.length + payload.length
|
|
80
|
+
const frame = new Uint8Array(totalLen)
|
|
81
|
+
let offset = 0
|
|
82
|
+
|
|
83
|
+
frame[offset++] = frameType
|
|
84
|
+
frame[offset++] = compIdBytes.length
|
|
85
|
+
frame.set(compIdBytes, offset); offset += compIdBytes.length
|
|
86
|
+
frame[offset++] = roomIdBytes.length
|
|
87
|
+
frame.set(roomIdBytes, offset); offset += roomIdBytes.length
|
|
88
|
+
frame[offset++] = (eventBytes.length >> 8) & 0xff
|
|
89
|
+
frame[offset++] = eventBytes.length & 0xff
|
|
90
|
+
frame.set(eventBytes, offset); offset += eventBytes.length
|
|
91
|
+
frame.set(payload, offset)
|
|
92
|
+
|
|
93
|
+
return frame
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const BINARY_ROOM_EVENT = 0x02
|
|
97
|
+
const BINARY_ROOM_STATE = 0x03
|
|
98
|
+
|
|
99
|
+
// ===== RoomManager binary frame tests =====
|
|
100
|
+
|
|
101
|
+
describe('RoomManager binary frame handling', () => {
|
|
102
|
+
let manager: RoomManager
|
|
103
|
+
let binaryHandler: ((frame: Uint8Array) => void) | null
|
|
104
|
+
let jsonHandlers: Set<(msg: RoomServerMessage) => void>
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
binaryHandler = null
|
|
108
|
+
jsonHandlers = new Set()
|
|
109
|
+
|
|
110
|
+
manager = new RoomManager({
|
|
111
|
+
componentId: 'comp-test-1',
|
|
112
|
+
sendMessage: vi.fn(),
|
|
113
|
+
sendMessageAndWait: vi.fn().mockResolvedValue({ success: true, state: {} }),
|
|
114
|
+
onMessage: (handler) => {
|
|
115
|
+
jsonHandlers.add(handler)
|
|
116
|
+
return () => { jsonHandlers.delete(handler) }
|
|
117
|
+
},
|
|
118
|
+
onBinaryMessage: (handler) => {
|
|
119
|
+
binaryHandler = handler
|
|
120
|
+
return () => { binaryHandler = null }
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Helper: join a room via JSON so the room exists in the manager
|
|
126
|
+
function joinRoom(roomId: string, initialState: any = {}) {
|
|
127
|
+
// Simulate the join by sending a JSON ROOM_JOINED message
|
|
128
|
+
const handle = manager.createHandle(roomId)
|
|
129
|
+
// Manually trigger ROOM_JOINED via JSON handler
|
|
130
|
+
for (const handler of jsonHandlers) {
|
|
131
|
+
handler({
|
|
132
|
+
type: 'ROOM_JOINED',
|
|
133
|
+
componentId: 'comp-test-1',
|
|
134
|
+
roomId,
|
|
135
|
+
event: '$room:joined',
|
|
136
|
+
data: { state: initialState },
|
|
137
|
+
timestamp: Date.now(),
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
return handle
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe('BINARY_ROOM_EVENT (0x02)', () => {
|
|
144
|
+
it('dispatches binary event to room handler', () => {
|
|
145
|
+
const handle = joinRoom('counter:global')
|
|
146
|
+
const eventHandler = vi.fn()
|
|
147
|
+
handle.on('counter:updated' as any, eventHandler)
|
|
148
|
+
|
|
149
|
+
// Send binary room event frame
|
|
150
|
+
const frame = buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'counter:global', 'counter:updated', {
|
|
151
|
+
count: 42,
|
|
152
|
+
updatedBy: 'Alice',
|
|
153
|
+
})
|
|
154
|
+
binaryHandler!(frame)
|
|
155
|
+
|
|
156
|
+
expect(eventHandler).toHaveBeenCalledTimes(1)
|
|
157
|
+
expect(eventHandler).toHaveBeenCalledWith({ count: 42, updatedBy: 'Alice' })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('ignores events for different componentId', () => {
|
|
161
|
+
const handle = joinRoom('counter:global')
|
|
162
|
+
const eventHandler = vi.fn()
|
|
163
|
+
handle.on('counter:updated' as any, eventHandler)
|
|
164
|
+
|
|
165
|
+
const frame = buildTestFrame(BINARY_ROOM_EVENT, 'comp-other', 'counter:global', 'counter:updated', { count: 1 })
|
|
166
|
+
binaryHandler!(frame)
|
|
167
|
+
|
|
168
|
+
expect(eventHandler).not.toHaveBeenCalled()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('ignores events for non-existent room', () => {
|
|
172
|
+
// Don't join any room
|
|
173
|
+
const frame = buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'unknown:room', 'some-event', {})
|
|
174
|
+
// Should not throw
|
|
175
|
+
binaryHandler!(frame)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('dispatches to multiple handlers', () => {
|
|
179
|
+
const handle = joinRoom('game:room')
|
|
180
|
+
const handler1 = vi.fn()
|
|
181
|
+
const handler2 = vi.fn()
|
|
182
|
+
handle.on('score:updated' as any, handler1)
|
|
183
|
+
handle.on('score:updated' as any, handler2)
|
|
184
|
+
|
|
185
|
+
const frame = buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'game:room', 'score:updated', { score: 100 })
|
|
186
|
+
binaryHandler!(frame)
|
|
187
|
+
|
|
188
|
+
expect(handler1).toHaveBeenCalledWith({ score: 100 })
|
|
189
|
+
expect(handler2).toHaveBeenCalledWith({ score: 100 })
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('handles handler errors without breaking other handlers', () => {
|
|
193
|
+
const handle = joinRoom('room:a')
|
|
194
|
+
const errorHandler = vi.fn(() => { throw new Error('test error') })
|
|
195
|
+
const goodHandler = vi.fn()
|
|
196
|
+
handle.on('evt' as any, errorHandler)
|
|
197
|
+
handle.on('evt' as any, goodHandler)
|
|
198
|
+
|
|
199
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
200
|
+
|
|
201
|
+
const frame = buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'room:a', 'evt', { x: 1 })
|
|
202
|
+
binaryHandler!(frame)
|
|
203
|
+
|
|
204
|
+
expect(errorHandler).toHaveBeenCalled()
|
|
205
|
+
expect(goodHandler).toHaveBeenCalledWith({ x: 1 })
|
|
206
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
207
|
+
|
|
208
|
+
consoleSpy.mockRestore()
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('BINARY_ROOM_STATE (0x03)', () => {
|
|
213
|
+
it('merges state update into room state', () => {
|
|
214
|
+
const handle = joinRoom('counter:global', { count: 0, onlineCount: 1, lastUpdatedBy: null })
|
|
215
|
+
|
|
216
|
+
const frame = buildTestFrame(BINARY_ROOM_STATE, 'comp-test-1', 'counter:global', '$state:update', {
|
|
217
|
+
state: { count: 42, lastUpdatedBy: 'Bob' },
|
|
218
|
+
})
|
|
219
|
+
binaryHandler!(frame)
|
|
220
|
+
|
|
221
|
+
expect(handle.state).toEqual({ count: 42, onlineCount: 1, lastUpdatedBy: 'Bob' })
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('triggers $state:change handlers', () => {
|
|
225
|
+
const handle = joinRoom('counter:global', { count: 0 })
|
|
226
|
+
const stateHandler = vi.fn()
|
|
227
|
+
// Internal event for state change
|
|
228
|
+
handle.on('$state:change' as any, stateHandler)
|
|
229
|
+
|
|
230
|
+
const frame = buildTestFrame(BINARY_ROOM_STATE, 'comp-test-1', 'counter:global', '$state:update', {
|
|
231
|
+
state: { count: 10 },
|
|
232
|
+
})
|
|
233
|
+
binaryHandler!(frame)
|
|
234
|
+
|
|
235
|
+
expect(stateHandler).toHaveBeenCalledWith({ count: 10 })
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('handles state without wrapper (data is the state directly)', () => {
|
|
239
|
+
const handle = joinRoom('room:x', { val: 0 })
|
|
240
|
+
|
|
241
|
+
// Some code paths send data directly without { state: ... } wrapper
|
|
242
|
+
const frame = buildTestFrame(BINARY_ROOM_STATE, 'comp-test-1', 'room:x', '$state:update', { val: 99 })
|
|
243
|
+
binaryHandler!(frame)
|
|
244
|
+
|
|
245
|
+
expect(handle.state).toEqual({ val: 99 })
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('deep merges nested state', () => {
|
|
249
|
+
const handle = joinRoom('game:1', {
|
|
250
|
+
players: { alice: { score: 10 } },
|
|
251
|
+
round: 1,
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const frame = buildTestFrame(BINARY_ROOM_STATE, 'comp-test-1', 'game:1', '$state:update', {
|
|
255
|
+
state: { players: { alice: { score: 20 } } },
|
|
256
|
+
})
|
|
257
|
+
binaryHandler!(frame)
|
|
258
|
+
|
|
259
|
+
expect(handle.state).toEqual({
|
|
260
|
+
players: { alice: { score: 20 } },
|
|
261
|
+
round: 1,
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('mixed binary + JSON', () => {
|
|
267
|
+
it('handles both binary and JSON messages for the same room', () => {
|
|
268
|
+
const handle = joinRoom('counter:global', { count: 0 })
|
|
269
|
+
const eventHandler = vi.fn()
|
|
270
|
+
handle.on('counter:updated' as any, eventHandler)
|
|
271
|
+
|
|
272
|
+
// Binary event
|
|
273
|
+
binaryHandler!(buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'counter:global', 'counter:updated', { count: 1, updatedBy: 'Alice' }))
|
|
274
|
+
|
|
275
|
+
// JSON event
|
|
276
|
+
for (const handler of jsonHandlers) {
|
|
277
|
+
handler({
|
|
278
|
+
type: 'ROOM_EVENT',
|
|
279
|
+
componentId: 'comp-test-1',
|
|
280
|
+
roomId: 'counter:global',
|
|
281
|
+
event: 'counter:updated',
|
|
282
|
+
data: { count: 2, updatedBy: 'Bob' },
|
|
283
|
+
timestamp: Date.now(),
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
expect(eventHandler).toHaveBeenCalledTimes(2)
|
|
288
|
+
expect(eventHandler).toHaveBeenNthCalledWith(1, { count: 1, updatedBy: 'Alice' })
|
|
289
|
+
expect(eventHandler).toHaveBeenNthCalledWith(2, { count: 2, updatedBy: 'Bob' })
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
describe('complex msgpack payloads', () => {
|
|
294
|
+
it('decodes arrays in binary events', () => {
|
|
295
|
+
const handle = joinRoom('chat:lobby')
|
|
296
|
+
const handler = vi.fn()
|
|
297
|
+
handle.on('messages:batch' as any, handler)
|
|
298
|
+
|
|
299
|
+
const messages = [
|
|
300
|
+
{ id: 1, text: 'hello', user: 'alice' },
|
|
301
|
+
{ id: 2, text: 'world', user: 'bob' },
|
|
302
|
+
]
|
|
303
|
+
binaryHandler!(buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'chat:lobby', 'messages:batch', messages))
|
|
304
|
+
|
|
305
|
+
expect(handler).toHaveBeenCalledWith(messages)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('decodes nested objects in binary state updates', () => {
|
|
309
|
+
const handle = joinRoom('game:complex', {
|
|
310
|
+
board: { cells: [] },
|
|
311
|
+
players: {},
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
const stateUpdate = {
|
|
315
|
+
state: {
|
|
316
|
+
board: { cells: [[1, 0], [0, 2]] },
|
|
317
|
+
players: {
|
|
318
|
+
p1: { name: 'Alice', score: 100, items: ['sword'] },
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
binaryHandler!(buildTestFrame(BINARY_ROOM_STATE, 'comp-test-1', 'game:complex', '$state:update', stateUpdate))
|
|
323
|
+
|
|
324
|
+
expect(handle.state.board.cells).toEqual([[1, 0], [0, 2]])
|
|
325
|
+
expect(handle.state.players.p1.name).toBe('Alice')
|
|
326
|
+
expect(handle.state.players.p1.items).toEqual(['sword'])
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('decodes boolean and null values correctly', () => {
|
|
330
|
+
const handle = joinRoom('room:types')
|
|
331
|
+
const handler = vi.fn()
|
|
332
|
+
handle.on('data:typed' as any, handler)
|
|
333
|
+
|
|
334
|
+
binaryHandler!(buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'room:types', 'data:typed', {
|
|
335
|
+
active: true,
|
|
336
|
+
deleted: false,
|
|
337
|
+
metadata: null,
|
|
338
|
+
count: 0,
|
|
339
|
+
name: '',
|
|
340
|
+
}))
|
|
341
|
+
|
|
342
|
+
expect(handler).toHaveBeenCalledWith({
|
|
343
|
+
active: true,
|
|
344
|
+
deleted: false,
|
|
345
|
+
metadata: null,
|
|
346
|
+
count: 0,
|
|
347
|
+
name: '',
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
describe('cleanup', () => {
|
|
353
|
+
it('unsubscribes binary handler on destroy', () => {
|
|
354
|
+
expect(binaryHandler).not.toBeNull()
|
|
355
|
+
manager.destroy()
|
|
356
|
+
expect(binaryHandler).toBeNull()
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
})
|