@fluxstack/live-client 0.1.0 → 0.2.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,7 @@ declare class LiveConnection {
45
45
  private reconnectTimeout;
46
46
  private heartbeatInterval;
47
47
  private componentCallbacks;
48
+ private binaryCallbacks;
48
49
  private pendingRequests;
49
50
  private stateListeners;
50
51
  private _state;
@@ -73,6 +74,10 @@ declare class LiveConnection {
73
74
  sendMessageAndWait(message: WebSocketMessage, timeout?: number): Promise<WebSocketResponse>;
74
75
  /** Send binary data and wait for response (for file uploads) */
75
76
  sendBinaryAndWait(data: ArrayBuffer, requestId: string, timeout?: number): Promise<WebSocketResponse>;
77
+ /** Parse and route a binary BINARY_STATE_DELTA frame */
78
+ private handleBinaryMessage;
79
+ /** Register a binary message handler for a component */
80
+ registerBinaryHandler(componentId: string, callback: (payload: Uint8Array) => void): () => void;
76
81
  /** Register a component message callback */
77
82
  registerComponent(componentId: string, callback: ComponentCallback): () => void;
78
83
  /** Unregister a component */
@@ -139,12 +144,25 @@ declare class LiveComponentHandle<TState extends Record<string, any> = Record<st
139
144
  * Returns the action's return value.
140
145
  */
141
146
  call<R = any>(action: string, payload?: Record<string, any>): Promise<R>;
147
+ /**
148
+ * Fire an action without waiting for a response (fire-and-forget).
149
+ * Useful for high-frequency operations like game input where the
150
+ * server doesn't need to send back a result.
151
+ */
152
+ fire(action: string, payload?: Record<string, any>): void;
142
153
  /**
143
154
  * Subscribe to state changes.
144
155
  * Callback receives the full new state and the delta (or null for full updates).
145
156
  * Returns an unsubscribe function.
146
157
  */
147
158
  onStateChange(callback: StateChangeCallback<TState>): () => void;
159
+ /**
160
+ * Register a binary decoder for this component.
161
+ * When the server sends a BINARY_STATE_DELTA frame targeting this component,
162
+ * the decoder converts the raw payload into a delta object which is merged into state.
163
+ * Returns an unsubscribe function.
164
+ */
165
+ setBinaryDecoder(decoder: (buffer: Uint8Array) => Record<string, any>): () => void;
148
166
  /**
149
167
  * Subscribe to errors.
150
168
  * Returns an unsubscribe function.
@@ -356,4 +374,81 @@ declare class StateValidator {
356
374
  static updateValidation<T>(hybridState: HybridState<T>, source?: 'client' | 'server' | 'mount'): HybridState<T>;
357
375
  }
358
376
 
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 };
377
+ /** Status listeners for the shared connection */
378
+ type ConnectionStatusCallback = (connected: boolean) => void;
379
+ interface UseLiveOptions {
380
+ /** WebSocket URL. Auto-detected from window.location if omitted. */
381
+ url?: string;
382
+ /** Room to join on mount */
383
+ room?: string;
384
+ /** User ID for component isolation */
385
+ userId?: string;
386
+ /** Auto-mount when connected. Default: true */
387
+ autoMount?: boolean;
388
+ /** Enable debug logging. Default: false */
389
+ debug?: boolean;
390
+ }
391
+ interface UseLiveHandle<TState extends Record<string, any> = Record<string, any>> {
392
+ /** Call a server action */
393
+ call: <R = any>(action: string, payload?: Record<string, any>) => Promise<R>;
394
+ /** Subscribe to state changes. Returns unsubscribe function. */
395
+ on: (callback: (state: TState, delta: Partial<TState> | null) => void) => () => void;
396
+ /** Subscribe to errors. Returns unsubscribe function. */
397
+ onError: (callback: (error: string) => void) => () => void;
398
+ /** Current state (read-only snapshot) */
399
+ readonly state: Readonly<TState>;
400
+ /** Whether the component is mounted on the server */
401
+ readonly mounted: boolean;
402
+ /** Server-assigned component ID */
403
+ readonly componentId: string | null;
404
+ /** Last error message */
405
+ readonly error: string | null;
406
+ /** Destroy the component and clean up */
407
+ destroy: () => void;
408
+ /** Access the underlying LiveComponentHandle */
409
+ readonly handle: LiveComponentHandle<TState>;
410
+ }
411
+ /**
412
+ * Create a live component with minimal boilerplate.
413
+ * Manages the WebSocket connection automatically (singleton).
414
+ *
415
+ * @example Browser IIFE
416
+ * ```html
417
+ * <script src="/live-client.js"></script>
418
+ * <script>
419
+ * const counter = FluxstackLive.useLive('Counter', { count: 0 })
420
+ * counter.on(state => {
421
+ * document.getElementById('count').textContent = state.count
422
+ * })
423
+ * document.querySelector('.inc').onclick = () => counter.call('increment')
424
+ * </script>
425
+ * ```
426
+ *
427
+ * @example ES modules
428
+ * ```ts
429
+ * import { useLive } from '@fluxstack/live-client'
430
+ * const counter = useLive('Counter', { count: 0 }, { url: 'ws://localhost:3000/api/live/ws' })
431
+ * counter.on(state => console.log(state.count))
432
+ * counter.call('increment')
433
+ * ```
434
+ */
435
+ declare function useLive<TState extends Record<string, any> = Record<string, any>>(componentName: string, initialState: TState, options?: UseLiveOptions): UseLiveHandle<TState>;
436
+ /**
437
+ * Subscribe to the shared connection status (connected/disconnected).
438
+ * Useful for showing a global status indicator.
439
+ *
440
+ * @example
441
+ * ```js
442
+ * FluxstackLive.onConnectionChange(connected => {
443
+ * statusEl.textContent = connected ? 'Connected' : 'Disconnected'
444
+ * })
445
+ * ```
446
+ */
447
+ declare function onConnectionChange(callback: ConnectionStatusCallback): () => void;
448
+ /**
449
+ * Get or create the shared connection instance.
450
+ * Useful when you need direct access to the connection.
451
+ */
452
+ declare function getConnection(url?: string): LiveConnection;
453
+
454
+ 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 };
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ var LiveConnection = class {
6
6
  reconnectTimeout = null;
7
7
  heartbeatInterval = null;
8
8
  componentCallbacks = /* @__PURE__ */ new Map();
9
+ binaryCallbacks = /* @__PURE__ */ new Map();
9
10
  pendingRequests = /* @__PURE__ */ new Map();
10
11
  stateListeners = /* @__PURE__ */ new Set();
11
12
  _state = {
@@ -86,6 +87,7 @@ var LiveConnection = class {
86
87
  this.log("Connecting...", { url });
87
88
  try {
88
89
  const ws = new WebSocket(url);
90
+ ws.binaryType = "arraybuffer";
89
91
  this.ws = ws;
90
92
  ws.onopen = () => {
91
93
  this.log("Connected");
@@ -94,19 +96,34 @@ var LiveConnection = class {
94
96
  this.startHeartbeat();
95
97
  };
96
98
  ws.onmessage = (event) => {
99
+ if (event.data instanceof ArrayBuffer) {
100
+ this.handleBinaryMessage(new Uint8Array(event.data));
101
+ return;
102
+ }
97
103
  try {
98
- const response = JSON.parse(event.data);
99
- this.log("Received", { type: response.type, componentId: response.componentId });
100
- this.handleMessage(response);
104
+ const parsed = JSON.parse(event.data);
105
+ if (Array.isArray(parsed)) {
106
+ for (const msg of parsed) {
107
+ this.log("Received", { type: msg.type, componentId: msg.componentId });
108
+ this.handleMessage(msg);
109
+ }
110
+ } else {
111
+ this.log("Received", { type: parsed.type, componentId: parsed.componentId });
112
+ this.handleMessage(parsed);
113
+ }
101
114
  } catch {
102
115
  this.log("Failed to parse message");
103
116
  this.setState({ error: "Failed to parse message" });
104
117
  }
105
118
  };
106
- ws.onclose = () => {
107
- this.log("Disconnected");
119
+ ws.onclose = (event) => {
120
+ this.log("Disconnected", { code: event.code, reason: event.reason });
108
121
  this.setState({ connected: false, connecting: false, connectionId: null });
109
122
  this.stopHeartbeat();
123
+ if (event.code === 4003) {
124
+ this.setState({ error: "Connection rejected: origin not allowed" });
125
+ return;
126
+ }
110
127
  this.attemptReconnect();
111
128
  };
112
129
  ws.onerror = () => {
@@ -281,6 +298,25 @@ var LiveConnection = class {
281
298
  }
282
299
  });
283
300
  }
301
+ /** Parse and route a binary BINARY_STATE_DELTA frame */
302
+ handleBinaryMessage(buffer) {
303
+ if (buffer.length < 3 || buffer[0] !== 1) return;
304
+ const idLen = buffer[1];
305
+ if (buffer.length < 2 + idLen) return;
306
+ const componentId = new TextDecoder().decode(buffer.subarray(2, 2 + idLen));
307
+ const payload = buffer.subarray(2 + idLen);
308
+ const callback = this.binaryCallbacks.get(componentId);
309
+ if (callback) {
310
+ callback(payload);
311
+ }
312
+ }
313
+ /** Register a binary message handler for a component */
314
+ registerBinaryHandler(componentId, callback) {
315
+ this.binaryCallbacks.set(componentId, callback);
316
+ return () => {
317
+ this.binaryCallbacks.delete(componentId);
318
+ };
319
+ }
284
320
  /** Register a component message callback */
285
321
  registerComponent(componentId, callback) {
286
322
  this.log("Registering component", componentId);
@@ -316,6 +352,7 @@ var LiveConnection = class {
316
352
  destroy() {
317
353
  this.disconnect();
318
354
  this.componentCallbacks.clear();
355
+ this.binaryCallbacks.clear();
319
356
  for (const [, req] of this.pendingRequests) {
320
357
  clearTimeout(req.timeout);
321
358
  req.reject(new Error("Connection destroyed"));
@@ -474,6 +511,21 @@ var LiveComponentHandle = class {
474
511
  }
475
512
  return response.result;
476
513
  }
514
+ /**
515
+ * Fire an action without waiting for a response (fire-and-forget).
516
+ * Useful for high-frequency operations like game input where the
517
+ * server doesn't need to send back a result.
518
+ */
519
+ fire(action, payload = {}) {
520
+ if (!this._mounted || !this._componentId) return;
521
+ this.connection.sendMessage({
522
+ type: "CALL_ACTION",
523
+ componentId: this._componentId,
524
+ action,
525
+ payload,
526
+ expectResponse: false
527
+ });
528
+ }
477
529
  // ── State ──
478
530
  /**
479
531
  * Subscribe to state changes.
@@ -486,6 +538,26 @@ var LiveComponentHandle = class {
486
538
  this.stateListeners.delete(callback);
487
539
  };
488
540
  }
541
+ /**
542
+ * Register a binary decoder for this component.
543
+ * When the server sends a BINARY_STATE_DELTA frame targeting this component,
544
+ * the decoder converts the raw payload into a delta object which is merged into state.
545
+ * Returns an unsubscribe function.
546
+ */
547
+ setBinaryDecoder(decoder) {
548
+ if (!this._componentId) {
549
+ throw new Error("Component must be mounted before setting binary decoder");
550
+ }
551
+ return this.connection.registerBinaryHandler(this._componentId, (payload) => {
552
+ try {
553
+ const delta = decoder(payload);
554
+ this._state = { ...this._state, ...delta };
555
+ this.notifyStateChange(this._state, delta);
556
+ } catch (e) {
557
+ console.error("Binary decode error:", e);
558
+ }
559
+ });
560
+ }
489
561
  /**
490
562
  * Subscribe to errors.
491
563
  * Returns an unsubscribe function.
@@ -1133,6 +1205,70 @@ var StateValidator = class {
1133
1205
  };
1134
1206
  }
1135
1207
  };
1208
+
1209
+ // src/index.ts
1210
+ var _sharedConnection = null;
1211
+ var _sharedConnectionUrl = null;
1212
+ var _statusListeners = /* @__PURE__ */ new Set();
1213
+ function getOrCreateConnection(url) {
1214
+ const resolvedUrl = url ?? `ws://${typeof location !== "undefined" ? location.host : "localhost:3000"}/api/live/ws`;
1215
+ if (_sharedConnection && _sharedConnectionUrl === resolvedUrl) {
1216
+ return _sharedConnection;
1217
+ }
1218
+ if (_sharedConnection) {
1219
+ _sharedConnection.destroy();
1220
+ }
1221
+ _sharedConnection = new LiveConnection({ url: resolvedUrl });
1222
+ _sharedConnectionUrl = resolvedUrl;
1223
+ _sharedConnection.onStateChange((state) => {
1224
+ for (const cb of _statusListeners) {
1225
+ cb(state.connected);
1226
+ }
1227
+ });
1228
+ return _sharedConnection;
1229
+ }
1230
+ function useLive(componentName, initialState, options = {}) {
1231
+ const { url, room, userId, autoMount = true, debug = false } = options;
1232
+ const connection = getOrCreateConnection(url);
1233
+ const handle = new LiveComponentHandle(connection, componentName, {
1234
+ initialState,
1235
+ room,
1236
+ userId,
1237
+ autoMount,
1238
+ debug
1239
+ });
1240
+ return {
1241
+ call: (action, payload) => handle.call(action, payload ?? {}),
1242
+ on: (callback) => handle.onStateChange(callback),
1243
+ onError: (callback) => handle.onError(callback),
1244
+ get state() {
1245
+ return handle.state;
1246
+ },
1247
+ get mounted() {
1248
+ return handle.mounted;
1249
+ },
1250
+ get componentId() {
1251
+ return handle.componentId;
1252
+ },
1253
+ get error() {
1254
+ return handle.error;
1255
+ },
1256
+ destroy: () => handle.destroy(),
1257
+ handle
1258
+ };
1259
+ }
1260
+ function onConnectionChange(callback) {
1261
+ _statusListeners.add(callback);
1262
+ if (_sharedConnection) {
1263
+ callback(_sharedConnection.state.connected);
1264
+ }
1265
+ return () => {
1266
+ _statusListeners.delete(callback);
1267
+ };
1268
+ }
1269
+ function getConnection(url) {
1270
+ return getOrCreateConnection(url);
1271
+ }
1136
1272
  export {
1137
1273
  AdaptiveChunkSizer,
1138
1274
  ChunkedUploader,
@@ -1142,7 +1278,10 @@ export {
1142
1278
  StateValidator,
1143
1279
  clearPersistedState,
1144
1280
  createBinaryChunkMessage,
1281
+ getConnection,
1145
1282
  getPersistedState,
1146
- persistState
1283
+ onConnectionChange,
1284
+ persistState,
1285
+ useLive
1147
1286
  };
1148
1287
  //# sourceMappingURL=index.js.map