@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.
@@ -0,0 +1,364 @@
1
+ // @fluxstack/live-client - LiveComponentHandle
2
+ //
3
+ // High-level vanilla JS wrapper for live components.
4
+ // Equivalent to Live.use() in @fluxstack/live-react but without React.
5
+ //
6
+ // Usage:
7
+ // const connection = new LiveConnection({ url: 'ws://...' })
8
+ // const counter = new LiveComponentHandle(connection, 'Counter', { count: 0 })
9
+ // counter.onStateChange((state) => updateUI(state))
10
+ // await counter.mount()
11
+ // await counter.call('increment')
12
+
13
+ import type { WebSocketResponse } from '@fluxstack/live'
14
+ import type { LiveConnection } from './connection'
15
+
16
+ // ===== Deep Merge (always-on, retrocompatible) =====
17
+
18
+ function isPlainObject(v: unknown): v is Record<string, any> {
19
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
20
+ && Object.getPrototypeOf(v) === Object.prototype
21
+ }
22
+
23
+ function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>, seen?: Set<object>): T {
24
+ if (!seen) seen = new Set()
25
+ if (seen.has(source as object)) return target
26
+ seen.add(source as object)
27
+
28
+ const result = { ...target }
29
+ for (const key of Object.keys(source) as Array<keyof T>) {
30
+ const newVal = source[key]
31
+ const oldVal = result[key]
32
+ if (isPlainObject(oldVal) && isPlainObject(newVal)) {
33
+ result[key] = deepMerge(oldVal as any, newVal as any, seen)
34
+ } else {
35
+ result[key] = newVal as T[keyof T]
36
+ }
37
+ }
38
+ return result
39
+ }
40
+
41
+ export interface LiveComponentOptions<TState = Record<string, any>> {
42
+ /** Initial state to merge with server defaults */
43
+ initialState?: Partial<TState>
44
+ /** Room to join on mount */
45
+ room?: string
46
+ /** User ID for component isolation */
47
+ userId?: string
48
+ /** Auto-mount when connection is ready. Default: true */
49
+ autoMount?: boolean
50
+ /** Enable debug logging. Default: false */
51
+ debug?: boolean
52
+ }
53
+
54
+ type StateChangeCallback<TState> = (state: TState, delta: Partial<TState> | null) => void
55
+ type ErrorCallback = (error: string) => void
56
+
57
+ /**
58
+ * High-level handle for a live component instance.
59
+ * Manages mount lifecycle, state sync, and action calling.
60
+ * Framework-agnostic — works with vanilla JS, Vue, Svelte, etc.
61
+ */
62
+ export class LiveComponentHandle<TState extends Record<string, any> = Record<string, any>> {
63
+ private connection: LiveConnection
64
+ private componentName: string
65
+ private options: Required<Omit<LiveComponentOptions<TState>, 'initialState' | 'room' | 'userId'>> & {
66
+ initialState: Partial<TState>
67
+ room?: string
68
+ userId?: string
69
+ }
70
+
71
+ private _componentId: string | null = null
72
+ private _state: TState
73
+ private _mounted = false
74
+ private _mounting = false
75
+ private _error: string | null = null
76
+
77
+ private stateListeners = new Set<StateChangeCallback<TState>>()
78
+ private errorListeners = new Set<ErrorCallback>()
79
+ private unregisterComponent: (() => void) | null = null
80
+ private unsubConnection: (() => void) | null = null
81
+
82
+ constructor(
83
+ connection: LiveConnection,
84
+ componentName: string,
85
+ options: LiveComponentOptions<TState> = {},
86
+ ) {
87
+ this.connection = connection
88
+ this.componentName = componentName
89
+ this._state = (options.initialState ?? {}) as TState
90
+
91
+ this.options = {
92
+ initialState: options.initialState ?? {},
93
+ room: options.room,
94
+ userId: options.userId,
95
+ autoMount: options.autoMount ?? true,
96
+ debug: options.debug ?? false,
97
+ }
98
+
99
+ // Auto-mount when connection is ready
100
+ if (this.options.autoMount) {
101
+ if (this.connection.state.connected) {
102
+ this.mount()
103
+ }
104
+ this.unsubConnection = this.connection.onStateChange((connState) => {
105
+ if (connState.connected && !this._mounted && !this._mounting) {
106
+ this.mount()
107
+ }
108
+ })
109
+ }
110
+ }
111
+
112
+ // ── Getters ──
113
+
114
+ /** Current component state */
115
+ get state(): Readonly<TState> { return this._state }
116
+
117
+ /** Server-assigned component ID (null before mount) */
118
+ get componentId(): string | null { return this._componentId }
119
+
120
+ /** Whether the component has been mounted */
121
+ get mounted(): boolean { return this._mounted }
122
+
123
+ /** Whether the component is currently mounting */
124
+ get mounting(): boolean { return this._mounting }
125
+
126
+ /** Last error message */
127
+ get error(): string | null { return this._error }
128
+
129
+ // ── Lifecycle ──
130
+
131
+ /** Mount the component on the server */
132
+ async mount(): Promise<void> {
133
+ if (this._mounted || this._mounting) return
134
+ if (!this.connection.state.connected) {
135
+ throw new Error('Cannot mount: not connected')
136
+ }
137
+
138
+ this._mounting = true
139
+ this._error = null
140
+ this.log('Mounting...')
141
+
142
+ try {
143
+ const response = await this.connection.sendMessageAndWait({
144
+ type: 'COMPONENT_MOUNT',
145
+ componentId: `mount-${this.componentName}`,
146
+ payload: {
147
+ component: this.componentName,
148
+ props: this.options.initialState,
149
+ room: this.options.room,
150
+ userId: this.options.userId,
151
+ },
152
+ })
153
+
154
+ if (!response.success) {
155
+ throw new Error(response.error || 'Mount failed')
156
+ }
157
+
158
+ const result = (response as any).result
159
+ this._componentId = result.componentId
160
+ this._mounted = true
161
+ this._mounting = false
162
+
163
+ // Merge initial state from server
164
+ const serverState = result.initialState || {}
165
+ this._state = { ...this._state, ...serverState }
166
+
167
+ // Register for component messages (state updates, deltas, errors)
168
+ this.unregisterComponent = this.connection.registerComponent(
169
+ this._componentId!,
170
+ (msg) => this.handleServerMessage(msg),
171
+ )
172
+
173
+ this.log('Mounted', { componentId: this._componentId })
174
+ this.notifyStateChange(this._state, null)
175
+ } catch (err) {
176
+ this._mounting = false
177
+ const errorMsg = err instanceof Error ? err.message : String(err)
178
+ this._error = errorMsg
179
+ this.notifyError(errorMsg)
180
+ throw err
181
+ }
182
+ }
183
+
184
+ /** Unmount the component from the server */
185
+ async unmount(): Promise<void> {
186
+ if (!this._mounted || !this._componentId) return
187
+
188
+ this.log('Unmounting...')
189
+
190
+ try {
191
+ await this.connection.sendMessage({
192
+ type: 'COMPONENT_UNMOUNT',
193
+ componentId: this._componentId,
194
+ })
195
+ } catch {
196
+ // Ignore unmount errors (connection may already be closed)
197
+ }
198
+
199
+ this.cleanup()
200
+ }
201
+
202
+ /** Destroy the handle and clean up all resources */
203
+ destroy(): void {
204
+ this.unmount().catch(() => {})
205
+ if (this.unsubConnection) {
206
+ this.unsubConnection()
207
+ this.unsubConnection = null
208
+ }
209
+ this.stateListeners.clear()
210
+ this.errorListeners.clear()
211
+ }
212
+
213
+ // ── Actions ──
214
+
215
+ /**
216
+ * Call an action on the server component.
217
+ * Returns the action's return value.
218
+ */
219
+ async call<R = any>(action: string, payload: Record<string, any> = {}): Promise<R> {
220
+ if (!this._mounted || !this._componentId) {
221
+ throw new Error(`Cannot call '${action}': component not mounted`)
222
+ }
223
+
224
+ this.log(`Calling action: ${action}`, payload)
225
+
226
+ const response = await this.connection.sendMessageAndWait({
227
+ type: 'CALL_ACTION',
228
+ componentId: this._componentId,
229
+ action,
230
+ payload,
231
+ })
232
+
233
+ if (!response.success) {
234
+ const errorMsg = response.error || `Action '${action}' failed`
235
+ this._error = errorMsg
236
+ this.notifyError(errorMsg)
237
+ throw new Error(errorMsg)
238
+ }
239
+
240
+ return (response as any).result
241
+ }
242
+
243
+ /**
244
+ * Fire an action without waiting for a response (fire-and-forget).
245
+ * Useful for high-frequency operations like game input where the
246
+ * server doesn't need to send back a result.
247
+ */
248
+ fire(action: string, payload: Record<string, any> = {}): void {
249
+ if (!this._mounted || !this._componentId) return
250
+
251
+ this.connection.sendMessage({
252
+ type: 'CALL_ACTION',
253
+ componentId: this._componentId,
254
+ action,
255
+ payload,
256
+ expectResponse: false,
257
+ } as any)
258
+ }
259
+
260
+ // ── State ──
261
+
262
+ /**
263
+ * Subscribe to state changes.
264
+ * Callback receives the full new state and the delta (or null for full updates).
265
+ * Returns an unsubscribe function.
266
+ */
267
+ onStateChange(callback: StateChangeCallback<TState>): () => void {
268
+ this.stateListeners.add(callback)
269
+ return () => { this.stateListeners.delete(callback) }
270
+ }
271
+
272
+ /**
273
+ * Register a binary decoder for this component.
274
+ * When the server sends a BINARY_STATE_DELTA frame targeting this component,
275
+ * the decoder converts the raw payload into a delta object which is merged into state.
276
+ * Returns an unsubscribe function.
277
+ */
278
+ setBinaryDecoder(decoder: (buffer: Uint8Array) => Record<string, any>): () => void {
279
+ if (!this._componentId) {
280
+ throw new Error('Component must be mounted before setting binary decoder')
281
+ }
282
+
283
+ return this.connection.registerBinaryHandler(this._componentId, (payload: Uint8Array) => {
284
+ try {
285
+ const delta = decoder(payload) as Partial<TState>
286
+ this._state = deepMerge(this._state, delta) as TState
287
+ this.notifyStateChange(this._state, delta as Partial<TState>)
288
+ } catch (e) {
289
+ console.error('Binary decode error:', e)
290
+ }
291
+ })
292
+ }
293
+
294
+ /**
295
+ * Subscribe to errors.
296
+ * Returns an unsubscribe function.
297
+ */
298
+ onError(callback: ErrorCallback): () => void {
299
+ this.errorListeners.add(callback)
300
+ return () => { this.errorListeners.delete(callback) }
301
+ }
302
+
303
+ // ── Internal ──
304
+
305
+ private handleServerMessage(msg: WebSocketResponse): void {
306
+ switch (msg.type) {
307
+ case 'STATE_UPDATE': {
308
+ const newState = (msg as any).payload?.state
309
+ if (newState) {
310
+ this._state = deepMerge(this._state, newState)
311
+ this.notifyStateChange(this._state, null)
312
+ }
313
+ break
314
+ }
315
+
316
+ case 'STATE_DELTA': {
317
+ const delta = (msg as any).payload?.delta
318
+ if (delta) {
319
+ this._state = deepMerge(this._state, delta)
320
+ this.notifyStateChange(this._state, delta)
321
+ }
322
+ break
323
+ }
324
+
325
+ case 'ERROR': {
326
+ const error = (msg as any).error || 'Unknown error'
327
+ this._error = error
328
+ this.notifyError(error)
329
+ break
330
+ }
331
+
332
+ default:
333
+ this.log('Unhandled message type:', msg.type)
334
+ }
335
+ }
336
+
337
+ private notifyStateChange(state: TState, delta: Partial<TState> | null): void {
338
+ for (const cb of this.stateListeners) {
339
+ cb(state, delta)
340
+ }
341
+ }
342
+
343
+ private notifyError(error: string): void {
344
+ for (const cb of this.errorListeners) {
345
+ cb(error)
346
+ }
347
+ }
348
+
349
+ private cleanup(): void {
350
+ if (this.unregisterComponent) {
351
+ this.unregisterComponent()
352
+ this.unregisterComponent = null
353
+ }
354
+ this._componentId = null
355
+ this._mounted = false
356
+ this._mounting = false
357
+ }
358
+
359
+ private log(message: string, data?: any): void {
360
+ if (this.options.debug) {
361
+ console.log(`[Live:${this.componentName}] ${message}`, data ?? '')
362
+ }
363
+ }
364
+ }