@fluxstack/live-client 0.5.0 → 0.6.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/index.cjs +30 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +30 -4
- package/dist/index.js.map +1 -1
- package/dist/live-client.browser.global.js +31 -4
- 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 +524 -0
- package/src/index.ts +219 -0
- package/src/persistence.ts +52 -0
- package/src/rooms.ts +539 -0
- package/src/state-validator.ts +121 -0
- package/src/upload.ts +366 -0
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
|
+
}
|