@fluxstack/live-client 0.1.0 → 0.3.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/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # @fluxstack/live-client
2
+
3
+ Framework-agnostic browser client for `@fluxstack/live`.
4
+
5
+ Works with any UI framework (React, Vue, Svelte, vanilla JS) or no framework at all. For React-specific bindings, see `@fluxstack/live-react`.
6
+
7
+ ## Installation
8
+
9
+ ### NPM/Bun
10
+
11
+ ```bash
12
+ bun add @fluxstack/live-client
13
+ ```
14
+
15
+ ### Browser (IIFE)
16
+
17
+ ```html
18
+ <script src="/live-client.js"></script>
19
+ <script>
20
+ const counter = FluxstackLive.useLive('Counter', { count: 0 })
21
+ </script>
22
+ ```
23
+
24
+ The IIFE bundle is served automatically by any transport adapter at `/live-client.js`.
25
+
26
+ ## Quick Start
27
+
28
+ ### Vanilla JS (useLive)
29
+
30
+ ```typescript
31
+ import { useLive, onConnectionChange } from '@fluxstack/live-client'
32
+
33
+ // Mount a component (uses singleton connection)
34
+ const counter = useLive('Counter', { count: 0 })
35
+
36
+ // Listen for state changes
37
+ counter.on(state => {
38
+ document.getElementById('count').textContent = state.count
39
+ })
40
+
41
+ // Call server actions
42
+ document.getElementById('btn').onclick = () => counter.call('increment')
43
+
44
+ // Connection status
45
+ onConnectionChange(connected => {
46
+ console.log(connected ? 'Online' : 'Offline')
47
+ })
48
+ ```
49
+
50
+ ### Manual Connection
51
+
52
+ ```typescript
53
+ import { LiveConnection, LiveComponentHandle } from '@fluxstack/live-client'
54
+
55
+ const conn = new LiveConnection({
56
+ url: 'ws://localhost:3000/api/live/ws',
57
+ autoReconnect: true,
58
+ reconnectInterval: 1000,
59
+ })
60
+ await conn.connect()
61
+
62
+ const counter = new LiveComponentHandle(conn, {
63
+ componentName: 'Counter',
64
+ defaultState: { count: 0 },
65
+ })
66
+
67
+ counter.on(state => console.log('Count:', state.count))
68
+ await counter.call('increment')
69
+ ```
70
+
71
+ ## API
72
+
73
+ ### `useLive(componentName, defaultState, options?)`
74
+
75
+ High-level API using a shared connection singleton:
76
+
77
+ ```typescript
78
+ const handle = useLive('Counter', { count: 0 }, {
79
+ url: 'ws://localhost:3000/api/live/ws', // Auto-detected if omitted
80
+ room: 'my-room', // Optional room
81
+ singleton: true, // Singleton mount
82
+ })
83
+
84
+ handle.on(state => { ... }) // State change listener
85
+ handle.call('action', payload) // Call server action
86
+ handle.destroy() // Unmount component
87
+ ```
88
+
89
+ ### `onConnectionChange(callback)`
90
+
91
+ Subscribe to connection status changes:
92
+
93
+ ```typescript
94
+ const unsubscribe = onConnectionChange(connected => { ... })
95
+ ```
96
+
97
+ ### `getConnection()`
98
+
99
+ Access the shared connection singleton:
100
+
101
+ ```typescript
102
+ const conn = getConnection()
103
+ ```
104
+
105
+ ### `LiveConnection`
106
+
107
+ WebSocket connection manager with auto-reconnect, state rehydration, and message routing:
108
+
109
+ ```typescript
110
+ const conn = new LiveConnection({
111
+ url: 'ws://localhost:3000/api/live/ws',
112
+ autoReconnect: true,
113
+ reconnectInterval: 1000,
114
+ auth: {
115
+ token: 'my-jwt-token',
116
+ provider: 'jwt',
117
+ },
118
+ })
119
+ ```
120
+
121
+ ### `LiveComponentHandle`
122
+
123
+ Handle to a remote component. Manages mounting, state updates, and action calls:
124
+
125
+ ```typescript
126
+ const handle = new LiveComponentHandle(conn, {
127
+ componentName: 'Counter',
128
+ defaultState: { count: 0 },
129
+ room: 'optional-room',
130
+ })
131
+
132
+ handle.on(state => { ... }) // State listener
133
+ handle.call('action', payload) // Call action
134
+ handle.$state // Current state
135
+ handle.$connected // Connection status
136
+ handle.destroy() // Unmount
137
+ ```
138
+
139
+ ### `RoomManager`
140
+
141
+ Client-side room event management:
142
+
143
+ ```typescript
144
+ const rooms = new RoomManager(conn)
145
+ const room = rooms.join('chat-room')
146
+ room.on('message:new', data => { ... })
147
+ room.emit('typing', { user: 'Alice' })
148
+ room.leave()
149
+ ```
150
+
151
+ ### `ChunkedUploader`
152
+
153
+ Stream file uploads over WebSocket:
154
+
155
+ ```typescript
156
+ const uploader = new ChunkedUploader(conn, {
157
+ chunkSize: 64 * 1024,
158
+ onProgress: (progress) => { ... },
159
+ })
160
+ await uploader.upload(file)
161
+ ```
162
+
163
+ ### State Persistence
164
+
165
+ ```typescript
166
+ import { persistState, getPersistedState, clearPersistedState } from '@fluxstack/live-client'
167
+
168
+ persistState('Counter', componentId, state, signature)
169
+ const saved = getPersistedState('Counter', componentId)
170
+ clearPersistedState('Counter', componentId)
171
+ ```
172
+
173
+ ## Browser IIFE Build
174
+
175
+ The package includes a pre-built browser bundle at `dist/live-client.browser.global.js` that exposes a `FluxstackLive` global with all exports. Transport adapters serve this automatically at `/live-client.js`.
176
+
177
+ ## License
178
+
179
+ MIT
package/dist/index.d.ts CHANGED
@@ -45,6 +45,8 @@ declare class LiveConnection {
45
45
  private reconnectTimeout;
46
46
  private heartbeatInterval;
47
47
  private componentCallbacks;
48
+ private binaryCallbacks;
49
+ private roomBinaryHandlers;
48
50
  private pendingRequests;
49
51
  private stateListeners;
50
52
  private _state;
@@ -73,6 +75,12 @@ declare class LiveConnection {
73
75
  sendMessageAndWait(message: WebSocketMessage, timeout?: number): Promise<WebSocketResponse>;
74
76
  /** Send binary data and wait for response (for file uploads) */
75
77
  sendBinaryAndWait(data: ArrayBuffer, requestId: string, timeout?: number): Promise<WebSocketResponse>;
78
+ /** Parse and route binary frames (state delta, room events, room state) */
79
+ private handleBinaryMessage;
80
+ /** Register a handler for binary room frames (0x02 / 0x03). Returns unsubscribe. */
81
+ registerRoomBinaryHandler(callback: (frame: Uint8Array) => void): () => void;
82
+ /** Register a binary message handler for a component */
83
+ registerBinaryHandler(componentId: string, callback: (payload: Uint8Array) => void): () => void;
76
84
  /** Register a component message callback */
77
85
  registerComponent(componentId: string, callback: ComponentCallback): () => void;
78
86
  /** Unregister a component */
@@ -139,12 +147,25 @@ declare class LiveComponentHandle<TState extends Record<string, any> = Record<st
139
147
  * Returns the action's return value.
140
148
  */
141
149
  call<R = any>(action: string, payload?: Record<string, any>): Promise<R>;
150
+ /**
151
+ * Fire an action without waiting for a response (fire-and-forget).
152
+ * Useful for high-frequency operations like game input where the
153
+ * server doesn't need to send back a result.
154
+ */
155
+ fire(action: string, payload?: Record<string, any>): void;
142
156
  /**
143
157
  * Subscribe to state changes.
144
158
  * Callback receives the full new state and the delta (or null for full updates).
145
159
  * Returns an unsubscribe function.
146
160
  */
147
161
  onStateChange(callback: StateChangeCallback<TState>): () => void;
162
+ /**
163
+ * Register a binary decoder for this component.
164
+ * When the server sends a BINARY_STATE_DELTA frame targeting this component,
165
+ * the decoder converts the raw payload into a delta object which is merged into state.
166
+ * Returns an unsubscribe function.
167
+ */
168
+ setBinaryDecoder(decoder: (buffer: Uint8Array) => Record<string, any>): () => void;
148
169
  /**
149
170
  * Subscribe to errors.
150
171
  * Returns an unsubscribe function.
@@ -159,6 +180,12 @@ declare class LiveComponentHandle<TState extends Record<string, any> = Record<st
159
180
 
160
181
  type EventHandler<T = any> = (data: T) => void;
161
182
  type Unsubscribe = () => void;
183
+ /** Reserved keys on RoomHandle/RoomProxy — cannot be state fields */
184
+ type RoomReservedKeys = 'id' | 'joined' | 'state' | 'join' | 'leave' | 'emit' | 'on' | 'onSystem' | 'setState';
185
+ /** State fields accessible directly on handle/proxy (excludes reserved method names) */
186
+ type RoomStateFields<TState> = TState extends Record<string, any> ? {
187
+ readonly [K in Exclude<keyof TState, RoomReservedKeys>]: TState[K];
188
+ } : unknown;
162
189
  /** Message from client to server */
163
190
  interface RoomClientMessage {
164
191
  type: 'ROOM_JOIN' | 'ROOM_LEAVE' | 'ROOM_EMIT' | 'ROOM_STATE_GET' | 'ROOM_STATE_SET';
@@ -178,7 +205,7 @@ interface RoomServerMessage {
178
205
  timestamp: number;
179
206
  }
180
207
  /** Interface of an individual room handle */
181
- interface RoomHandle<TState = any, TEvents extends Record<string, any> = Record<string, any>> {
208
+ type RoomHandle<TState = any, TEvents extends Record<string, any> = Record<string, any>> = {
182
209
  readonly id: string;
183
210
  readonly joined: boolean;
184
211
  readonly state: TState;
@@ -188,10 +215,16 @@ interface RoomHandle<TState = any, TEvents extends Record<string, any> = Record<
188
215
  on: <K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>) => Unsubscribe;
189
216
  onSystem: (event: string, handler: EventHandler) => Unsubscribe;
190
217
  setState: (updates: Partial<TState>) => void;
191
- }
218
+ } & RoomStateFields<TState>;
219
+ /** Infer TEvents from a LiveRoom class (via $events brand) or use T directly as events map */
220
+ type InferRoomEvents<T> = T extends {
221
+ $events: infer E extends Record<string, any>;
222
+ } ? E : T extends Record<string, any> ? T : Record<string, any>;
192
223
  /** Proxy interface for $room - callable as function or object */
193
- interface RoomProxy<TState = any, TEvents extends Record<string, any> = Record<string, any>> {
194
- (roomId: string): RoomHandle<TState, TEvents>;
224
+ type RoomProxy<TState = any, TEvents extends Record<string, any> = Record<string, any>> = {
225
+ /** Get a typed room handle. Pass the Room class or events interface as generic:
226
+ * `$room<CounterRoom>('counter:global').on('counter:updated', data => ...)` */
227
+ <T = TEvents>(roomId: string): RoomHandle<any, InferRoomEvents<T>>;
195
228
  readonly id: string | null;
196
229
  readonly joined: boolean;
197
230
  readonly state: TState;
@@ -201,13 +234,15 @@ interface RoomProxy<TState = any, TEvents extends Record<string, any> = Record<s
201
234
  on: <K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>) => Unsubscribe;
202
235
  onSystem: (event: string, handler: EventHandler) => Unsubscribe;
203
236
  setState: (updates: Partial<TState>) => void;
204
- }
237
+ } & RoomStateFields<TState>;
205
238
  interface RoomManagerOptions {
206
239
  componentId: string | null;
207
240
  defaultRoom?: string;
208
241
  sendMessage: (msg: any) => void;
209
242
  sendMessageAndWait: (msg: any, timeout?: number) => Promise<any>;
210
243
  onMessage: (handler: (msg: RoomServerMessage) => void) => Unsubscribe;
244
+ /** Optional: register for binary room frames (0x02 ROOM_EVENT, 0x03 ROOM_STATE) */
245
+ onBinaryMessage?: (handler: (frame: Uint8Array) => void) => Unsubscribe;
211
246
  }
212
247
  /** Client-side room manager. Framework-agnostic. */
213
248
  declare class RoomManager<TState = any, TEvents extends Record<string, any> = Record<string, any>> {
@@ -218,8 +253,15 @@ declare class RoomManager<TState = any, TEvents extends Record<string, any> = Re
218
253
  private sendMessage;
219
254
  private sendMessageAndWait;
220
255
  private globalUnsubscribe;
256
+ private binaryUnsubscribe;
257
+ private onBinaryMessage;
258
+ private onMessageFactory;
221
259
  constructor(options: RoomManagerOptions);
260
+ /** Re-subscribe message and binary handlers (needed after destroy/remount in React Strict Mode) */
261
+ resubscribe(): void;
222
262
  private handleServerMessage;
263
+ /** Handle binary room frames (0x02 ROOM_EVENT, 0x03 ROOM_STATE) */
264
+ private handleBinaryFrame;
223
265
  private getOrCreateRoom;
224
266
  /** Create handle for a specific room (cached) */
225
267
  createHandle(roomId: string): RoomHandle<TState, TEvents>;
@@ -229,7 +271,7 @@ declare class RoomManager<TState = any, TEvents extends Record<string, any> = Re
229
271
  getJoinedRooms(): string[];
230
272
  /** Update componentId (when component mounts) */
231
273
  setComponentId(id: string | null): void;
232
- /** Cleanup */
274
+ /** Cleanup — unsubscribes handlers but keeps factory refs for resubscribe() */
233
275
  destroy(): void;
234
276
  }
235
277
 
@@ -356,4 +398,81 @@ declare class StateValidator {
356
398
  static updateValidation<T>(hybridState: HybridState<T>, source?: 'client' | 'server' | 'mount'): HybridState<T>;
357
399
  }
358
400
 
359
- export { type AdaptiveChunkConfig, AdaptiveChunkSizer, type ChunkMetrics, type ChunkedUploadOptions, type ChunkedUploadState, ChunkedUploader, type EventHandler, type HybridState, type LiveAuthOptions, LiveComponentHandle, type LiveComponentOptions, LiveConnection, type LiveConnectionOptions, type LiveConnectionState, type PersistedState, type RoomClientMessage, type RoomHandle, RoomManager, type RoomManagerOptions, type RoomProxy, type RoomServerMessage, type StateConflict, type StateValidation, StateValidator, type Unsubscribe, clearPersistedState, createBinaryChunkMessage, getPersistedState, persistState };
401
+ /** Status listeners for the shared connection */
402
+ type ConnectionStatusCallback = (connected: boolean) => void;
403
+ interface UseLiveOptions {
404
+ /** WebSocket URL. Auto-detected from window.location if omitted. */
405
+ url?: string;
406
+ /** Room to join on mount */
407
+ room?: string;
408
+ /** User ID for component isolation */
409
+ userId?: string;
410
+ /** Auto-mount when connected. Default: true */
411
+ autoMount?: boolean;
412
+ /** Enable debug logging. Default: false */
413
+ debug?: boolean;
414
+ }
415
+ interface UseLiveHandle<TState extends Record<string, any> = Record<string, any>> {
416
+ /** Call a server action */
417
+ call: <R = any>(action: string, payload?: Record<string, any>) => Promise<R>;
418
+ /** Subscribe to state changes. Returns unsubscribe function. */
419
+ on: (callback: (state: TState, delta: Partial<TState> | null) => void) => () => void;
420
+ /** Subscribe to errors. Returns unsubscribe function. */
421
+ onError: (callback: (error: string) => void) => () => void;
422
+ /** Current state (read-only snapshot) */
423
+ readonly state: Readonly<TState>;
424
+ /** Whether the component is mounted on the server */
425
+ readonly mounted: boolean;
426
+ /** Server-assigned component ID */
427
+ readonly componentId: string | null;
428
+ /** Last error message */
429
+ readonly error: string | null;
430
+ /** Destroy the component and clean up */
431
+ destroy: () => void;
432
+ /** Access the underlying LiveComponentHandle */
433
+ readonly handle: LiveComponentHandle<TState>;
434
+ }
435
+ /**
436
+ * Create a live component with minimal boilerplate.
437
+ * Manages the WebSocket connection automatically (singleton).
438
+ *
439
+ * @example Browser IIFE
440
+ * ```html
441
+ * <script src="/live-client.js"></script>
442
+ * <script>
443
+ * const counter = FluxstackLive.useLive('Counter', { count: 0 })
444
+ * counter.on(state => {
445
+ * document.getElementById('count').textContent = state.count
446
+ * })
447
+ * document.querySelector('.inc').onclick = () => counter.call('increment')
448
+ * </script>
449
+ * ```
450
+ *
451
+ * @example ES modules
452
+ * ```ts
453
+ * import { useLive } from '@fluxstack/live-client'
454
+ * const counter = useLive('Counter', { count: 0 }, { url: 'ws://localhost:3000/api/live/ws' })
455
+ * counter.on(state => console.log(state.count))
456
+ * counter.call('increment')
457
+ * ```
458
+ */
459
+ declare function useLive<TState extends Record<string, any> = Record<string, any>>(componentName: string, initialState: TState, options?: UseLiveOptions): UseLiveHandle<TState>;
460
+ /**
461
+ * Subscribe to the shared connection status (connected/disconnected).
462
+ * Useful for showing a global status indicator.
463
+ *
464
+ * @example
465
+ * ```js
466
+ * FluxstackLive.onConnectionChange(connected => {
467
+ * statusEl.textContent = connected ? 'Connected' : 'Disconnected'
468
+ * })
469
+ * ```
470
+ */
471
+ declare function onConnectionChange(callback: ConnectionStatusCallback): () => void;
472
+ /**
473
+ * Get or create the shared connection instance.
474
+ * Useful when you need direct access to the connection.
475
+ */
476
+ declare function getConnection(url?: string): LiveConnection;
477
+
478
+ export { type AdaptiveChunkConfig, AdaptiveChunkSizer, type ChunkMetrics, type ChunkedUploadOptions, type ChunkedUploadState, ChunkedUploader, type EventHandler, type HybridState, type LiveAuthOptions, LiveComponentHandle, type LiveComponentOptions, LiveConnection, type LiveConnectionOptions, type LiveConnectionState, type PersistedState, type RoomClientMessage, type RoomHandle, RoomManager, type RoomManagerOptions, type RoomProxy, type RoomServerMessage, type StateConflict, type StateValidation, StateValidator, type Unsubscribe, type UseLiveHandle, type UseLiveOptions, clearPersistedState, createBinaryChunkMessage, getConnection, getPersistedState, onConnectionChange, persistState, useLive };