@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/src/index.ts ADDED
@@ -0,0 +1,219 @@
1
+ // @fluxstack/live-client - Framework-agnostic browser client
2
+ //
3
+ // This package provides the core WebSocket connection, room management,
4
+ // file upload, state persistence, and validation utilities.
5
+ // It has NO dependency on any UI framework (React, Vue, etc.).
6
+ //
7
+ // Quick start (browser IIFE):
8
+ // const counter = FluxstackLive.useLive('Counter', { count: 0 })
9
+ // counter.on(state => document.getElementById('count').textContent = state.count)
10
+ // counter.call('increment')
11
+
12
+ // Connection
13
+ export { LiveConnection } from './connection'
14
+ export type {
15
+ LiveAuthOptions,
16
+ LiveClientAuth,
17
+ LiveConnectionOptions,
18
+ LiveConnectionState,
19
+ } from './connection'
20
+
21
+ // Component Handle (vanilla JS equivalent of Live.use)
22
+ export { LiveComponentHandle } from './component'
23
+ export type { LiveComponentOptions } from './component'
24
+
25
+ // Rooms
26
+ export { RoomManager } from './rooms'
27
+ export type {
28
+ RoomClientMessage,
29
+ RoomServerMessage,
30
+ RoomHandle,
31
+ RoomProxy,
32
+ RoomManagerOptions,
33
+ EventHandler,
34
+ Unsubscribe,
35
+ } from './rooms'
36
+
37
+ // Upload
38
+ export {
39
+ AdaptiveChunkSizer,
40
+ ChunkedUploader,
41
+ createBinaryChunkMessage,
42
+ } from './upload'
43
+ export type {
44
+ AdaptiveChunkConfig,
45
+ ChunkMetrics,
46
+ ChunkedUploadOptions,
47
+ ChunkedUploadState,
48
+ } from './upload'
49
+
50
+ // Persistence
51
+ export {
52
+ persistState,
53
+ getPersistedState,
54
+ clearPersistedState,
55
+ } from './persistence'
56
+ export type { PersistedState } from './persistence'
57
+
58
+ // State Validation
59
+ export { StateValidator } from './state-validator'
60
+ export type {
61
+ StateValidation,
62
+ StateConflict,
63
+ HybridState,
64
+ } from './state-validator'
65
+
66
+ // ===== useLive — simplified API for vanilla JS =====
67
+
68
+ import { LiveConnection } from './connection'
69
+ import type { LiveConnectionOptions } from './connection'
70
+ import { LiveComponentHandle } from './component'
71
+ import type { LiveComponentOptions } from './component'
72
+
73
+ /** Shared connection singleton — created once, reused by all useLive() calls */
74
+ let _sharedConnection: LiveConnection | null = null
75
+ let _sharedConnectionUrl: string | null = null
76
+
77
+ /** Status listeners for the shared connection */
78
+ type ConnectionStatusCallback = (connected: boolean) => void
79
+ const _statusListeners = new Set<ConnectionStatusCallback>()
80
+
81
+ function getOrCreateConnection(url?: string): LiveConnection {
82
+ const resolvedUrl = url ?? `ws://${typeof location !== 'undefined' ? location.host : 'localhost:3000'}/api/live/ws`
83
+
84
+ // Reuse existing connection if same URL
85
+ if (_sharedConnection && _sharedConnectionUrl === resolvedUrl) {
86
+ return _sharedConnection
87
+ }
88
+
89
+ // Destroy old connection if URL changed
90
+ if (_sharedConnection) {
91
+ _sharedConnection.destroy()
92
+ }
93
+
94
+ _sharedConnection = new LiveConnection({ url: resolvedUrl })
95
+ _sharedConnectionUrl = resolvedUrl
96
+
97
+ _sharedConnection.onStateChange((state) => {
98
+ for (const cb of _statusListeners) {
99
+ cb(state.connected)
100
+ }
101
+ })
102
+
103
+ return _sharedConnection
104
+ }
105
+
106
+ export interface UseLiveOptions {
107
+ /** WebSocket URL. Auto-detected from window.location if omitted. */
108
+ url?: string
109
+ /** Room to join on mount */
110
+ room?: string
111
+ /** User ID for component isolation */
112
+ userId?: string
113
+ /** Auto-mount when connected. Default: true */
114
+ autoMount?: boolean
115
+ /** Enable debug logging. Default: false */
116
+ debug?: boolean
117
+ }
118
+
119
+ export interface UseLiveHandle<TState extends Record<string, any> = Record<string, any>> {
120
+ /** Call a server action */
121
+ call: <R = any>(action: string, payload?: Record<string, any>) => Promise<R>
122
+ /** Subscribe to state changes. Returns unsubscribe function. */
123
+ on: (callback: (state: TState, delta: Partial<TState> | null) => void) => () => void
124
+ /** Subscribe to errors. Returns unsubscribe function. */
125
+ onError: (callback: (error: string) => void) => () => void
126
+ /** Current state (read-only snapshot) */
127
+ readonly state: Readonly<TState>
128
+ /** Whether the component is mounted on the server */
129
+ readonly mounted: boolean
130
+ /** Server-assigned component ID */
131
+ readonly componentId: string | null
132
+ /** Last error message */
133
+ readonly error: string | null
134
+ /** Destroy the component and clean up */
135
+ destroy: () => void
136
+ /** Access the underlying LiveComponentHandle */
137
+ readonly handle: LiveComponentHandle<TState>
138
+ }
139
+
140
+ /**
141
+ * Create a live component with minimal boilerplate.
142
+ * Manages the WebSocket connection automatically (singleton).
143
+ *
144
+ * @example Browser IIFE
145
+ * ```html
146
+ * <script src="/live-client.js"></script>
147
+ * <script>
148
+ * const counter = FluxstackLive.useLive('Counter', { count: 0 })
149
+ * counter.on(state => {
150
+ * document.getElementById('count').textContent = state.count
151
+ * })
152
+ * document.querySelector('.inc').onclick = () => counter.call('increment')
153
+ * </script>
154
+ * ```
155
+ *
156
+ * @example ES modules
157
+ * ```ts
158
+ * import { useLive } from '@fluxstack/live-client'
159
+ * const counter = useLive('Counter', { count: 0 }, { url: 'ws://localhost:3000/api/live/ws' })
160
+ * counter.on(state => console.log(state.count))
161
+ * counter.call('increment')
162
+ * ```
163
+ */
164
+ export function useLive<TState extends Record<string, any> = Record<string, any>>(
165
+ componentName: string,
166
+ initialState: TState,
167
+ options: UseLiveOptions = {},
168
+ ): UseLiveHandle<TState> {
169
+ const { url, room, userId, autoMount = true, debug = false } = options
170
+
171
+ const connection = getOrCreateConnection(url)
172
+ const handle = new LiveComponentHandle<TState>(connection, componentName, {
173
+ initialState,
174
+ room,
175
+ userId,
176
+ autoMount,
177
+ debug,
178
+ })
179
+
180
+ return {
181
+ call: (action, payload) => handle.call(action, payload ?? {}),
182
+ on: (callback) => handle.onStateChange(callback),
183
+ onError: (callback) => handle.onError(callback),
184
+ get state() { return handle.state },
185
+ get mounted() { return handle.mounted },
186
+ get componentId() { return handle.componentId },
187
+ get error() { return handle.error },
188
+ destroy: () => handle.destroy(),
189
+ handle,
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Subscribe to the shared connection status (connected/disconnected).
195
+ * Useful for showing a global status indicator.
196
+ *
197
+ * @example
198
+ * ```js
199
+ * FluxstackLive.onConnectionChange(connected => {
200
+ * statusEl.textContent = connected ? 'Connected' : 'Disconnected'
201
+ * })
202
+ * ```
203
+ */
204
+ export function onConnectionChange(callback: ConnectionStatusCallback): () => void {
205
+ _statusListeners.add(callback)
206
+ // Immediately fire with current state if connection exists
207
+ if (_sharedConnection) {
208
+ callback(_sharedConnection.state.connected)
209
+ }
210
+ return () => { _statusListeners.delete(callback) }
211
+ }
212
+
213
+ /**
214
+ * Get or create the shared connection instance.
215
+ * Useful when you need direct access to the connection.
216
+ */
217
+ export function getConnection(url?: string): LiveConnection {
218
+ return getOrCreateConnection(url)
219
+ }
@@ -0,0 +1,52 @@
1
+ // @fluxstack/live-client - State Persistence
2
+ //
3
+ // Utilities for persisting and recovering component state via localStorage.
4
+
5
+ const STORAGE_KEY_PREFIX = 'fluxstack_component_'
6
+ const STATE_MAX_AGE = 24 * 60 * 60 * 1000 // 24 hours
7
+
8
+ export interface PersistedState {
9
+ componentName: string
10
+ signedState: any
11
+ room?: string
12
+ userId?: string
13
+ lastUpdate: number
14
+ }
15
+
16
+ export function persistState(
17
+ enabled: boolean,
18
+ name: string,
19
+ signedState: any,
20
+ room?: string,
21
+ userId?: string,
22
+ ): void {
23
+ if (!enabled) return
24
+ try {
25
+ localStorage.setItem(`${STORAGE_KEY_PREFIX}${name}`, JSON.stringify({
26
+ componentName: name, signedState, room, userId, lastUpdate: Date.now(),
27
+ }))
28
+ } catch (e) {
29
+ if (typeof console !== 'undefined') {
30
+ console.warn(`[fluxstack] Failed to persist state for '${name}':`, e instanceof Error ? e.message : e)
31
+ }
32
+ }
33
+ }
34
+
35
+ export function getPersistedState(enabled: boolean, name: string): PersistedState | null {
36
+ if (!enabled) return null
37
+ try {
38
+ const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${name}`)
39
+ if (!stored) return null
40
+ const state: PersistedState = JSON.parse(stored)
41
+ if (Date.now() - state.lastUpdate > STATE_MAX_AGE) {
42
+ localStorage.removeItem(`${STORAGE_KEY_PREFIX}${name}`)
43
+ return null
44
+ }
45
+ return state
46
+ } catch { return null }
47
+ }
48
+
49
+ export function clearPersistedState(enabled: boolean, name: string): void {
50
+ if (!enabled) return
51
+ try { localStorage.removeItem(`${STORAGE_KEY_PREFIX}${name}`) } catch {}
52
+ }