@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
package/src/component.ts
ADDED
|
@@ -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
|
+
}
|