@fluxstack/live-client 0.5.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 +9 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/live-client.browser.global.js +9 -2
- 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/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,48 @@
|
|
|
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 {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getPersistedState(enabled: boolean, name: string): PersistedState | null {
|
|
32
|
+
if (!enabled) return null
|
|
33
|
+
try {
|
|
34
|
+
const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${name}`)
|
|
35
|
+
if (!stored) return null
|
|
36
|
+
const state: PersistedState = JSON.parse(stored)
|
|
37
|
+
if (Date.now() - state.lastUpdate > STATE_MAX_AGE) {
|
|
38
|
+
localStorage.removeItem(`${STORAGE_KEY_PREFIX}${name}`)
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
return state
|
|
42
|
+
} catch { return null }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function clearPersistedState(enabled: boolean, name: string): void {
|
|
46
|
+
if (!enabled) return
|
|
47
|
+
try { localStorage.removeItem(`${STORAGE_KEY_PREFIX}${name}`) } catch {}
|
|
48
|
+
}
|