@fluxstack/live-client 0.5.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/src/rooms.ts ADDED
@@ -0,0 +1,539 @@
1
+ // @fluxstack/live-client - Room Manager (Client-side)
2
+ //
3
+ // Framework-agnostic room system for managing multi-room WebSocket communication.
4
+ // Used by framework-specific adapters (React, Vue, etc.).
5
+
6
+ // ===== Deep Merge (always-on, retrocompatible) =====
7
+
8
+ function isPlainObject(v: unknown): v is Record<string, any> {
9
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
10
+ && Object.getPrototypeOf(v) === Object.prototype
11
+ }
12
+
13
+ function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>, seen?: Set<object>): T {
14
+ if (!seen) seen = new Set()
15
+ if (seen.has(source as object)) return target
16
+ seen.add(source as object)
17
+
18
+ const result = { ...target }
19
+ for (const key of Object.keys(source) as Array<keyof T>) {
20
+ const newVal = source[key]
21
+ const oldVal = result[key]
22
+ if (isPlainObject(oldVal) && isPlainObject(newVal)) {
23
+ result[key] = deepMerge(oldVal as any, newVal as any, seen)
24
+ } else {
25
+ result[key] = newVal as T[keyof T]
26
+ }
27
+ }
28
+ return result
29
+ }
30
+
31
+ type EventHandler<T = any> = (data: T) => void
32
+ type Unsubscribe = () => void
33
+
34
+ // ===== Binary Room Frame Constants =====
35
+
36
+ const BINARY_ROOM_EVENT = 0x02
37
+ const BINARY_ROOM_STATE = 0x03
38
+
39
+ // ===== Lightweight msgpack decoder (client-side, decode-only) =====
40
+
41
+ const _decoder = new TextDecoder()
42
+
43
+ function msgpackDecode(buf: Uint8Array): unknown {
44
+ return _decodeAt(buf, 0).value
45
+ }
46
+
47
+ function _decodeAt(buf: Uint8Array, offset: number): { value: unknown; offset: number } {
48
+ if (offset >= buf.length) return { value: null, offset }
49
+ const byte = buf[offset]
50
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
51
+
52
+ if (byte < 0x80) return { value: byte, offset: offset + 1 }
53
+ if (byte >= 0x80 && byte <= 0x8f) return _decodeMap(buf, offset + 1, byte & 0x0f)
54
+ if (byte >= 0x90 && byte <= 0x9f) return _decodeArr(buf, offset + 1, byte & 0x0f)
55
+ if (byte >= 0xa0 && byte <= 0xbf) {
56
+ const len = byte & 0x1f
57
+ return { value: _decoder.decode(buf.subarray(offset + 1, offset + 1 + len)), offset: offset + 1 + len }
58
+ }
59
+ if (byte >= 0xe0) return { value: byte - 256, offset: offset + 1 }
60
+
61
+ switch (byte) {
62
+ case 0xc0: return { value: null, offset: offset + 1 }
63
+ case 0xc2: return { value: false, offset: offset + 1 }
64
+ case 0xc3: return { value: true, offset: offset + 1 }
65
+ case 0xc4: { const l = buf[offset + 1]; return { value: buf.slice(offset + 2, offset + 2 + l), offset: offset + 2 + l } }
66
+ case 0xc5: { const l = view.getUint16(offset + 1, false); return { value: buf.slice(offset + 3, offset + 3 + l), offset: offset + 3 + l } }
67
+ case 0xc6: { const l = view.getUint32(offset + 1, false); return { value: buf.slice(offset + 5, offset + 5 + l), offset: offset + 5 + l } }
68
+ case 0xcb: return { value: view.getFloat64(offset + 1, false), offset: offset + 9 }
69
+ case 0xcc: return { value: buf[offset + 1], offset: offset + 2 }
70
+ case 0xcd: return { value: view.getUint16(offset + 1, false), offset: offset + 3 }
71
+ case 0xce: return { value: view.getUint32(offset + 1, false), offset: offset + 5 }
72
+ case 0xd0: return { value: view.getInt8(offset + 1), offset: offset + 2 }
73
+ case 0xd1: return { value: view.getInt16(offset + 1, false), offset: offset + 3 }
74
+ case 0xd2: return { value: view.getInt32(offset + 1, false), offset: offset + 5 }
75
+ case 0xd9: { const l = buf[offset + 1]; return { value: _decoder.decode(buf.subarray(offset + 2, offset + 2 + l)), offset: offset + 2 + l } }
76
+ case 0xda: { const l = view.getUint16(offset + 1, false); return { value: _decoder.decode(buf.subarray(offset + 3, offset + 3 + l)), offset: offset + 3 + l } }
77
+ case 0xdb: { const l = view.getUint32(offset + 1, false); return { value: _decoder.decode(buf.subarray(offset + 5, offset + 5 + l)), offset: offset + 5 + l } }
78
+ case 0xdc: return _decodeArr(buf, offset + 3, view.getUint16(offset + 1, false))
79
+ case 0xdd: return _decodeArr(buf, offset + 5, view.getUint32(offset + 1, false))
80
+ case 0xde: return _decodeMap(buf, offset + 3, view.getUint16(offset + 1, false))
81
+ case 0xdf: return _decodeMap(buf, offset + 5, view.getUint32(offset + 1, false))
82
+ }
83
+ return { value: null, offset: offset + 1 }
84
+ }
85
+
86
+ function _decodeArr(buf: Uint8Array, offset: number, count: number): { value: unknown[]; offset: number } {
87
+ const arr: unknown[] = new Array(count)
88
+ for (let i = 0; i < count; i++) { const r = _decodeAt(buf, offset); arr[i] = r.value; offset = r.offset }
89
+ return { value: arr, offset }
90
+ }
91
+
92
+ function _decodeMap(buf: Uint8Array, offset: number, count: number): { value: Record<string, unknown>; offset: number } {
93
+ const obj: Record<string, unknown> = {}
94
+ for (let i = 0; i < count; i++) {
95
+ const k = _decodeAt(buf, offset); offset = k.offset
96
+ const v = _decodeAt(buf, offset); offset = v.offset
97
+ obj[String(k.value)] = v.value
98
+ }
99
+ return { value: obj, offset }
100
+ }
101
+
102
+ /** Parse a binary room frame: [frameType][compIdLen][compId][roomIdLen][roomId][eventLen:u16][event][payload] */
103
+ function parseRoomFrame(buf: Uint8Array): {
104
+ frameType: number; componentId: string; roomId: string; event: string; payload: Uint8Array
105
+ } | null {
106
+ if (buf.length < 6) return null
107
+ let offset = 0
108
+ const frameType = buf[offset++]
109
+ const compIdLen = buf[offset++]
110
+ if (offset + compIdLen > buf.length) return null
111
+ const componentId = _decoder.decode(buf.subarray(offset, offset + compIdLen)); offset += compIdLen
112
+ const roomIdLen = buf[offset++]
113
+ if (offset + roomIdLen > buf.length) return null
114
+ const roomId = _decoder.decode(buf.subarray(offset, offset + roomIdLen)); offset += roomIdLen
115
+ if (offset + 2 > buf.length) return null
116
+ const eventLen = (buf[offset] << 8) | buf[offset + 1]; offset += 2
117
+ if (offset + eventLen > buf.length) return null
118
+ const event = _decoder.decode(buf.subarray(offset, offset + eventLen)); offset += eventLen
119
+ return { frameType, componentId, roomId, event, payload: buf.subarray(offset) }
120
+ }
121
+
122
+ /** Reserved property names on RoomHandle/RoomProxy (never fall through to state) */
123
+ const ROOM_RESERVED_KEYS = new Set<string | symbol>([
124
+ 'id', 'joined', 'state', 'join', 'leave', 'emit', 'on', 'onSystem', 'setState',
125
+ 'call', 'apply', 'bind', 'prototype', 'length', 'name', 'arguments', 'caller',
126
+ Symbol.toPrimitive, Symbol.toStringTag, Symbol.hasInstance,
127
+ ])
128
+
129
+ /** Wrap a handle/proxy so unknown property access falls through to state */
130
+ function wrapWithStateProxy<T extends object>(
131
+ target: T,
132
+ getState: () => any,
133
+ setStateFn: (updates: any) => void,
134
+ ): T {
135
+ return new Proxy(target, {
136
+ get(obj, prop, receiver) {
137
+ if (ROOM_RESERVED_KEYS.has(prop) || typeof prop === 'symbol') {
138
+ return Reflect.get(obj, prop, receiver)
139
+ }
140
+ const desc = Object.getOwnPropertyDescriptor(obj, prop)
141
+ if (desc) return Reflect.get(obj, prop, receiver)
142
+ if (prop in obj) return Reflect.get(obj, prop, receiver)
143
+ const st = getState()
144
+ return st?.[prop]
145
+ },
146
+ set(_obj, prop, value) {
147
+ if (typeof prop === 'symbol') return false
148
+ setStateFn({ [prop]: value })
149
+ return true
150
+ },
151
+ })
152
+ }
153
+
154
+ /** Reserved keys on RoomHandle/RoomProxy — cannot be state fields */
155
+ type RoomReservedKeys = 'id' | 'joined' | 'state' | 'join' | 'leave' | 'emit' | 'on' | 'onSystem' | 'setState'
156
+
157
+ /** State fields accessible directly on handle/proxy (excludes reserved method names) */
158
+ type RoomStateFields<TState> = TState extends Record<string, any>
159
+ ? { readonly [K in Exclude<keyof TState, RoomReservedKeys>]: TState[K] }
160
+ : unknown
161
+
162
+ /** Message from client to server */
163
+ export interface RoomClientMessage {
164
+ type: 'ROOM_JOIN' | 'ROOM_LEAVE' | 'ROOM_EMIT' | 'ROOM_STATE_GET' | 'ROOM_STATE_SET'
165
+ componentId: string
166
+ roomId: string
167
+ event?: string
168
+ data?: any
169
+ timestamp: number
170
+ }
171
+
172
+ /** Message from server to client */
173
+ export interface RoomServerMessage {
174
+ type: 'ROOM_EVENT' | 'ROOM_STATE' | 'ROOM_SYSTEM' | 'ROOM_JOINED' | 'ROOM_LEFT'
175
+ componentId: string
176
+ roomId: string
177
+ event: string
178
+ data: any
179
+ timestamp: number
180
+ }
181
+
182
+ /** Interface of an individual room handle */
183
+ export type RoomHandle<TState = any, TEvents extends Record<string, any> = Record<string, any>> = {
184
+ readonly id: string
185
+ readonly joined: boolean
186
+ readonly state: TState
187
+ join: (initialState?: TState) => Promise<void>
188
+ leave: () => Promise<void>
189
+ emit: <K extends keyof TEvents>(event: K, data: TEvents[K]) => void
190
+ on: <K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>) => Unsubscribe
191
+ onSystem: (event: string, handler: EventHandler) => Unsubscribe
192
+ setState: (updates: Partial<TState>) => void
193
+ } & RoomStateFields<TState>
194
+
195
+ /** Infer TEvents from a LiveRoom class (via $events brand) or use T directly as events map */
196
+ export type InferRoomEvents<T> =
197
+ T extends { $events: infer E extends Record<string, any> } ? E :
198
+ T extends Record<string, any> ? T :
199
+ Record<string, any>
200
+
201
+ /** Proxy interface for $room - callable as function or object */
202
+ export type RoomProxy<TState = any, TEvents extends Record<string, any> = Record<string, any>> = {
203
+ /** Get a typed room handle. Pass the Room class or events interface as generic:
204
+ * `$room<CounterRoom>('counter:global').on('counter:updated', data => ...)` */
205
+ <T = TEvents>(roomId: string): RoomHandle<any, InferRoomEvents<T>>
206
+ readonly id: string | null
207
+ readonly joined: boolean
208
+ readonly state: TState
209
+ join: (initialState?: TState) => Promise<void>
210
+ leave: () => Promise<void>
211
+ emit: <K extends keyof TEvents>(event: K, data: TEvents[K]) => void
212
+ on: <K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>) => Unsubscribe
213
+ onSystem: (event: string, handler: EventHandler) => Unsubscribe
214
+ setState: (updates: Partial<TState>) => void
215
+ } & RoomStateFields<TState>
216
+
217
+ export interface RoomManagerOptions {
218
+ componentId: string | null
219
+ defaultRoom?: string
220
+ sendMessage: (msg: any) => void
221
+ sendMessageAndWait: (msg: any, timeout?: number) => Promise<any>
222
+ onMessage: (handler: (msg: RoomServerMessage) => void) => Unsubscribe
223
+ /** Optional: register for binary room frames (0x02 ROOM_EVENT, 0x03 ROOM_STATE) */
224
+ onBinaryMessage?: (handler: (frame: Uint8Array) => void) => Unsubscribe
225
+ }
226
+
227
+ /** Client-side room manager. Framework-agnostic. */
228
+ export class RoomManager<TState = any, TEvents extends Record<string, any> = Record<string, any>> {
229
+ private componentId: string | null
230
+ private defaultRoom: string | null
231
+ private rooms = new Map<string, {
232
+ joined: boolean
233
+ state: TState
234
+ handlers: Map<string, Set<EventHandler>>
235
+ }>()
236
+ private handles = new Map<string, RoomHandle<TState, TEvents>>()
237
+ private sendMessage: (msg: any) => void
238
+ private sendMessageAndWait: (msg: any, timeout?: number) => Promise<any>
239
+ private globalUnsubscribe: Unsubscribe | null = null
240
+ private binaryUnsubscribe: Unsubscribe | null = null
241
+ private onBinaryMessage: ((handler: (frame: Uint8Array) => void) => Unsubscribe) | null = null
242
+ private onMessageFactory: ((handler: (msg: RoomServerMessage) => void) => Unsubscribe) | null = null
243
+
244
+ constructor(options: RoomManagerOptions) {
245
+ this.componentId = options.componentId
246
+ this.defaultRoom = options.defaultRoom || null
247
+ this.sendMessage = options.sendMessage
248
+ this.sendMessageAndWait = options.sendMessageAndWait
249
+ this.onBinaryMessage = options.onBinaryMessage ?? null
250
+ this.onMessageFactory = options.onMessage
251
+ this.globalUnsubscribe = options.onMessage((msg) => this.handleServerMessage(msg))
252
+ if (options.onBinaryMessage) {
253
+ this.binaryUnsubscribe = options.onBinaryMessage((frame) => this.handleBinaryFrame(frame))
254
+ }
255
+ }
256
+
257
+ /** Re-subscribe message and binary handlers (needed after destroy/remount in React Strict Mode) */
258
+ resubscribe(): void {
259
+ if (!this.globalUnsubscribe && this.onMessageFactory) {
260
+ this.globalUnsubscribe = this.onMessageFactory((msg) => this.handleServerMessage(msg))
261
+ }
262
+ if (!this.binaryUnsubscribe && this.onBinaryMessage) {
263
+ this.binaryUnsubscribe = this.onBinaryMessage((frame) => this.handleBinaryFrame(frame))
264
+ }
265
+ }
266
+
267
+ private handleServerMessage(msg: RoomServerMessage): void {
268
+ if (msg.componentId !== this.componentId) return
269
+
270
+ const room = this.rooms.get(msg.roomId)
271
+ if (!room) return
272
+
273
+ switch (msg.type) {
274
+ case 'ROOM_EVENT':
275
+ case 'ROOM_SYSTEM': {
276
+ const handlers = room.handlers.get(msg.event)
277
+ if (handlers) {
278
+ for (const handler of handlers) {
279
+ try { handler(msg.data) } catch (error) {
280
+ console.error(`[Room:${msg.roomId}] Handler error for '${msg.event}':`, error)
281
+ }
282
+ }
283
+ }
284
+ break
285
+ }
286
+
287
+ case 'ROOM_STATE': {
288
+ // Server sends data: { state: actualChanges } — extract the actual changes
289
+ const stateChanges = msg.data?.state ?? msg.data
290
+ room.state = deepMerge(room.state as Record<string, any>, stateChanges) as TState
291
+ const stateHandlers = room.handlers.get('$state:change')
292
+ if (stateHandlers) {
293
+ for (const handler of stateHandlers) handler(stateChanges)
294
+ }
295
+ break
296
+ }
297
+
298
+ case 'ROOM_JOINED':
299
+ room.joined = true
300
+ if (msg.data?.state) room.state = msg.data.state
301
+ break
302
+
303
+ case 'ROOM_LEFT':
304
+ room.joined = false
305
+ break
306
+ }
307
+ }
308
+
309
+ /** Handle binary room frames (0x02 ROOM_EVENT, 0x03 ROOM_STATE) */
310
+ private handleBinaryFrame(frame: Uint8Array): void {
311
+ const parsed = parseRoomFrame(frame)
312
+ if (!parsed) return
313
+ if (parsed.componentId !== this.componentId) return
314
+
315
+ const room = this.rooms.get(parsed.roomId)
316
+ if (!room) return
317
+
318
+ const data = msgpackDecode(parsed.payload)
319
+
320
+ if (parsed.frameType === BINARY_ROOM_EVENT) {
321
+ // Dispatch to event handlers
322
+ const handlers = room.handlers.get(parsed.event)
323
+ if (handlers) {
324
+ for (const handler of handlers) {
325
+ try { handler(data) } catch (error) {
326
+ console.error(`[Room:${parsed.roomId}] Handler error for '${parsed.event}':`, error)
327
+ }
328
+ }
329
+ }
330
+ } else if (parsed.frameType === BINARY_ROOM_STATE) {
331
+ // State update: data is { state: changes } or just changes
332
+ const stateChanges = (data as any)?.state ?? data
333
+ room.state = deepMerge(room.state as Record<string, any>, stateChanges as Record<string, any>) as TState
334
+ const stateHandlers = room.handlers.get('$state:change')
335
+ if (stateHandlers) {
336
+ for (const handler of stateHandlers) handler(stateChanges)
337
+ }
338
+ }
339
+ }
340
+
341
+ private getOrCreateRoom(roomId: string) {
342
+ if (!this.rooms.has(roomId)) {
343
+ this.rooms.set(roomId, {
344
+ joined: false,
345
+ state: {} as TState,
346
+ handlers: new Map(),
347
+ })
348
+ }
349
+ return this.rooms.get(roomId)!
350
+ }
351
+
352
+ /** Create handle for a specific room (cached) */
353
+ createHandle(roomId: string): RoomHandle<TState, TEvents> {
354
+ if (this.handles.has(roomId)) return this.handles.get(roomId)!
355
+
356
+ const room = this.getOrCreateRoom(roomId)
357
+
358
+ // RoomStateFields are fulfilled at runtime by the Proxy wrapper
359
+ const handle = {
360
+ get id() { return roomId },
361
+ get joined() { return room.joined },
362
+ get state() { return room.state },
363
+
364
+ join: async (initialState?: TState) => {
365
+ if (!this.componentId) throw new Error('Component not mounted')
366
+ if (room.joined) return
367
+
368
+ if (initialState) room.state = initialState
369
+
370
+ const response = await this.sendMessageAndWait({
371
+ type: 'ROOM_JOIN',
372
+ componentId: this.componentId,
373
+ roomId,
374
+ data: { initialState: room.state },
375
+ timestamp: Date.now(),
376
+ }, 5000)
377
+
378
+ if (response?.success) {
379
+ room.joined = true
380
+ if (response.state) room.state = response.state
381
+ }
382
+ },
383
+
384
+ leave: async () => {
385
+ if (!this.componentId || !room.joined) return
386
+
387
+ await this.sendMessageAndWait({
388
+ type: 'ROOM_LEAVE',
389
+ componentId: this.componentId,
390
+ roomId,
391
+ timestamp: Date.now(),
392
+ }, 5000)
393
+
394
+ room.joined = false
395
+ room.handlers.clear()
396
+ },
397
+
398
+ emit: <K extends keyof TEvents>(event: K, data: TEvents[K]) => {
399
+ if (!this.componentId) return
400
+ this.sendMessage({
401
+ type: 'ROOM_EMIT',
402
+ componentId: this.componentId,
403
+ roomId,
404
+ event: event as string,
405
+ data,
406
+ timestamp: Date.now(),
407
+ })
408
+ },
409
+
410
+ on: <K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe => {
411
+ const eventKey = event as string
412
+ if (!room.handlers.has(eventKey)) room.handlers.set(eventKey, new Set())
413
+ room.handlers.get(eventKey)!.add(handler)
414
+ return () => { room.handlers.get(eventKey)?.delete(handler) }
415
+ },
416
+
417
+ onSystem: (event: string, handler: EventHandler): Unsubscribe => {
418
+ const eventKey = `$${event}`
419
+ if (!room.handlers.has(eventKey)) room.handlers.set(eventKey, new Set())
420
+ room.handlers.get(eventKey)!.add(handler)
421
+ return () => { room.handlers.get(eventKey)?.delete(handler) }
422
+ },
423
+
424
+ setState: (updates: Partial<TState>) => {
425
+ if (!this.componentId) return
426
+ room.state = deepMerge(room.state as Record<string, any>, updates as Record<string, any>) as TState
427
+ this.sendMessage({
428
+ type: 'ROOM_STATE_SET',
429
+ componentId: this.componentId,
430
+ roomId,
431
+ data: updates,
432
+ timestamp: Date.now(),
433
+ })
434
+ },
435
+ }
436
+
437
+ const proxied = wrapWithStateProxy(
438
+ handle,
439
+ () => room.state,
440
+ (updates: Partial<TState>) => handle.setState(updates),
441
+ )
442
+ this.handles.set(roomId, proxied as RoomHandle<TState, TEvents>)
443
+ return proxied as RoomHandle<TState, TEvents>
444
+ }
445
+
446
+ /** Create the $room proxy */
447
+ createProxy(): RoomProxy<TState, TEvents> {
448
+ const self = this
449
+
450
+ const proxyFn = function(roomId: string): RoomHandle<TState, TEvents> {
451
+ return self.createHandle(roomId)
452
+ } as RoomProxy<TState, TEvents>
453
+
454
+ const defaultHandle = this.defaultRoom ? this.createHandle(this.defaultRoom) : null
455
+
456
+ Object.defineProperties(proxyFn, {
457
+ id: { get: () => this.defaultRoom },
458
+ joined: { get: () => defaultHandle?.joined ?? false },
459
+ state: { get: () => defaultHandle?.state ?? ({} as TState) },
460
+ join: {
461
+ value: async (initialState?: TState) => {
462
+ if (!defaultHandle) throw new Error('No default room set')
463
+ return defaultHandle.join(initialState)
464
+ },
465
+ },
466
+ leave: {
467
+ value: async () => {
468
+ if (!defaultHandle) throw new Error('No default room set')
469
+ return defaultHandle.leave()
470
+ },
471
+ },
472
+ emit: {
473
+ value: <K extends keyof TEvents>(event: K, data: TEvents[K]) => {
474
+ if (!defaultHandle) throw new Error('No default room set')
475
+ return defaultHandle.emit(event, data)
476
+ },
477
+ },
478
+ on: {
479
+ value: <K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe => {
480
+ if (!defaultHandle) throw new Error('No default room set')
481
+ return defaultHandle.on(event, handler)
482
+ },
483
+ },
484
+ onSystem: {
485
+ value: (event: string, handler: EventHandler): Unsubscribe => {
486
+ if (!defaultHandle) throw new Error('No default room set')
487
+ return defaultHandle.onSystem(event, handler)
488
+ },
489
+ },
490
+ setState: {
491
+ value: (updates: Partial<TState>) => {
492
+ if (!defaultHandle) throw new Error('No default room set')
493
+ return defaultHandle.setState(updates)
494
+ },
495
+ },
496
+ })
497
+
498
+ // Wrap top-level proxy so $room.players reads from default room state
499
+ if (this.defaultRoom && defaultHandle) {
500
+ const room = this.getOrCreateRoom(this.defaultRoom)
501
+ return wrapWithStateProxy(
502
+ proxyFn,
503
+ () => room.state,
504
+ (updates: Partial<TState>) => defaultHandle.setState(updates),
505
+ ) as RoomProxy<TState, TEvents>
506
+ }
507
+
508
+ return proxyFn
509
+ }
510
+
511
+ /** List of rooms currently joined */
512
+ getJoinedRooms(): string[] {
513
+ const joined: string[] = []
514
+ for (const [id, room] of this.rooms) {
515
+ if (room.joined) joined.push(id)
516
+ }
517
+ return joined
518
+ }
519
+
520
+ /** Update componentId (when component mounts) */
521
+ setComponentId(id: string | null): void {
522
+ this.componentId = id
523
+ }
524
+
525
+ /** Cleanup — unsubscribes handlers but keeps factory refs for resubscribe() */
526
+ destroy(): void {
527
+ this.globalUnsubscribe?.()
528
+ this.globalUnsubscribe = null
529
+ this.binaryUnsubscribe?.()
530
+ this.binaryUnsubscribe = null
531
+ for (const [, room] of this.rooms) {
532
+ room.handlers.clear()
533
+ }
534
+ this.rooms.clear()
535
+ this.handles.clear()
536
+ }
537
+ }
538
+
539
+ export type { EventHandler, Unsubscribe }
@@ -0,0 +1,121 @@
1
+ // @fluxstack/live-client - State Validation Utilities
2
+
3
+ export interface StateValidation {
4
+ checksum: string
5
+ version: number
6
+ timestamp: number
7
+ source: 'client' | 'server' | 'mount'
8
+ }
9
+
10
+ export interface StateConflict {
11
+ property: string
12
+ clientValue: any
13
+ serverValue: any
14
+ timestamp: number
15
+ resolved: boolean
16
+ }
17
+
18
+ export interface HybridState<T> {
19
+ data: T
20
+ validation: StateValidation
21
+ status: 'synced' | 'pending' | 'conflict'
22
+ }
23
+
24
+ export class StateValidator {
25
+ static generateChecksum(state: any): string {
26
+ const json = JSON.stringify(state, Object.keys(state).sort())
27
+ let hash = 0
28
+ for (let i = 0; i < json.length; i++) {
29
+ const char = json.charCodeAt(i)
30
+ hash = ((hash << 5) - hash) + char
31
+ hash = hash & hash
32
+ }
33
+ return Math.abs(hash).toString(16)
34
+ }
35
+
36
+ static createValidation(
37
+ state: any,
38
+ source: 'client' | 'server' | 'mount' = 'client',
39
+ ): StateValidation {
40
+ return {
41
+ checksum: this.generateChecksum(state),
42
+ version: Date.now(),
43
+ timestamp: Date.now(),
44
+ source,
45
+ }
46
+ }
47
+
48
+ static detectConflicts<T>(
49
+ clientState: T,
50
+ serverState: T,
51
+ excludeFields: string[] = ['lastUpdated', 'version'],
52
+ ): StateConflict[] {
53
+ const conflicts: StateConflict[] = []
54
+ const clientKeys = Object.keys(clientState as any)
55
+ const serverKeys = Object.keys(serverState as any)
56
+ const allKeys = Array.from(new Set([...clientKeys, ...serverKeys]))
57
+
58
+ for (const key of allKeys) {
59
+ if (excludeFields.includes(key)) continue
60
+ const clientValue = (clientState as any)?.[key]
61
+ const serverValue = (serverState as any)?.[key]
62
+ if (JSON.stringify(clientValue) !== JSON.stringify(serverValue)) {
63
+ conflicts.push({
64
+ property: key,
65
+ clientValue,
66
+ serverValue,
67
+ timestamp: Date.now(),
68
+ resolved: false,
69
+ })
70
+ }
71
+ }
72
+
73
+ return conflicts
74
+ }
75
+
76
+ static mergeStates<T>(
77
+ clientState: T,
78
+ serverState: T,
79
+ conflicts: StateConflict[],
80
+ strategy: 'client' | 'server' | 'smart' = 'smart',
81
+ ): T {
82
+ const merged = { ...clientState }
83
+
84
+ for (const conflict of conflicts) {
85
+ switch (strategy) {
86
+ case 'client':
87
+ break
88
+ case 'server':
89
+ (merged as any)[conflict.property] = conflict.serverValue
90
+ break
91
+ case 'smart':
92
+ if (conflict.property === 'lastUpdated') {
93
+ (merged as any)[conflict.property] = conflict.serverValue
94
+ } else if (typeof conflict.serverValue === 'number' && typeof conflict.clientValue === 'number') {
95
+ (merged as any)[conflict.property] = Math.max(conflict.serverValue, conflict.clientValue)
96
+ } else {
97
+ (merged as any)[conflict.property] = conflict.serverValue
98
+ }
99
+ break
100
+ }
101
+ }
102
+
103
+ return merged
104
+ }
105
+
106
+ static validateState<T>(hybridState: HybridState<T>): boolean {
107
+ const currentChecksum = this.generateChecksum(hybridState.data)
108
+ return currentChecksum === hybridState.validation.checksum
109
+ }
110
+
111
+ static updateValidation<T>(
112
+ hybridState: HybridState<T>,
113
+ source: 'client' | 'server' | 'mount' = 'client',
114
+ ): HybridState<T> {
115
+ return {
116
+ ...hybridState,
117
+ validation: this.createValidation(hybridState.data, source),
118
+ status: 'synced',
119
+ }
120
+ }
121
+ }