hyperstack-react 0.1.2
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/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/index.d.ts +255 -0
- package/dist/index.esm.js +834 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +845 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
import React, { createContext, useMemo, useRef, useEffect, useContext, useState, useCallback, useSyncExternalStore } from 'react';
|
|
2
|
+
import { create } from 'zustand';
|
|
3
|
+
|
|
4
|
+
// Sensible defaults - can be overridden per ConnectionManager instance
|
|
5
|
+
const DEFAULT_CONFIG = {
|
|
6
|
+
websocketUrl: 'ws://localhost:8080', // default local development server
|
|
7
|
+
reconnectIntervals: [1000, 2000, 4000, 8000, 16000], // exponential backoff: 1s, 2s, 4s, 8s, 16s
|
|
8
|
+
maxReconnectAttempts: 5, // retry 5 times before giving up (matches array length)
|
|
9
|
+
initialSubscriptions: [], // no global subscriptions by default
|
|
10
|
+
supportsUnsubscribe: false, // conservative default - many servers don't support this
|
|
11
|
+
autoSubscribeDefault: true, // hooks auto-subscribe by default
|
|
12
|
+
};
|
|
13
|
+
// Custom error class for SDK-specific errors with structured details
|
|
14
|
+
class HyperStreamError extends Error {
|
|
15
|
+
constructor(message, // human-readable error message
|
|
16
|
+
code, // machine-readable error code (e.g., 'CONNECTION_FAILED')
|
|
17
|
+
details // additional context (original error, frame data, etc.)
|
|
18
|
+
) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.details = details;
|
|
22
|
+
this.name = 'HyperStreamError'; // Proper error name for debugging/logging
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Manages WebSocket connection lifecycle and subscription queuing
|
|
27
|
+
class ConnectionManager {
|
|
28
|
+
constructor(config = {}) {
|
|
29
|
+
this.ws = null;
|
|
30
|
+
this.reconnectAttempts = 0;
|
|
31
|
+
this.reconnectTimeout = null;
|
|
32
|
+
this.pingInterval = null;
|
|
33
|
+
this.currentState = 'disconnected';
|
|
34
|
+
this.subscriptionQueue = [];
|
|
35
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
36
|
+
}
|
|
37
|
+
// set callbacks
|
|
38
|
+
setHandlers(handlers) {
|
|
39
|
+
this.onFrame = handlers.onFrame;
|
|
40
|
+
this.onStateChange = handlers.onStateChange;
|
|
41
|
+
}
|
|
42
|
+
// public getters for external state inspection
|
|
43
|
+
getState() {
|
|
44
|
+
return this.currentState;
|
|
45
|
+
}
|
|
46
|
+
getConfig() {
|
|
47
|
+
return this.config;
|
|
48
|
+
}
|
|
49
|
+
// Initiate WebSocket connection with subscription restoration
|
|
50
|
+
connect() {
|
|
51
|
+
// prevent duplicate connections
|
|
52
|
+
if (this.ws?.readyState === WebSocket.OPEN ||
|
|
53
|
+
this.ws?.readyState === WebSocket.CONNECTING ||
|
|
54
|
+
this.currentState === 'connecting') {
|
|
55
|
+
console.log('Connection already exists or in progress, skipping connect');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
console.log('[Hyperstack] Connecting to WebSocket...');
|
|
59
|
+
this.updateState('connecting'); // update UI state immediately
|
|
60
|
+
try {
|
|
61
|
+
this.ws = new WebSocket(this.config.websocketUrl);
|
|
62
|
+
this.ws.onopen = () => {
|
|
63
|
+
console.log('[Hyperstack] WebSocket connected');
|
|
64
|
+
this.reconnectAttempts = 0; // reset retry counter on successful connect
|
|
65
|
+
this.updateState('connected'); // notify store/UI of successful connection
|
|
66
|
+
this.startPingInterval(); // start keep-alive ping
|
|
67
|
+
// send global subscriptions first (from config)
|
|
68
|
+
if (this.config.initialSubscriptions) {
|
|
69
|
+
for (const sub of this.config.initialSubscriptions) {
|
|
70
|
+
this.subscribe(sub);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// flush queued subscriptions from when we were offline
|
|
74
|
+
while (this.subscriptionQueue.length > 0) {
|
|
75
|
+
const sub = this.subscriptionQueue.shift();
|
|
76
|
+
this.subscribe(sub);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
// core message handler - parses incoming data into EntityFrames
|
|
80
|
+
this.ws.onmessage = async (event) => {
|
|
81
|
+
try {
|
|
82
|
+
let frame;
|
|
83
|
+
if (event.data instanceof ArrayBuffer) {
|
|
84
|
+
// binary data as ArrayBuffer
|
|
85
|
+
frame = this.parseBinaryFrame(event.data);
|
|
86
|
+
}
|
|
87
|
+
else if (event.data instanceof Blob) {
|
|
88
|
+
// binary data as Blob - convert to ArrayBuffer
|
|
89
|
+
const arrayBuffer = await event.data.arrayBuffer();
|
|
90
|
+
frame = this.parseBinaryFrame(arrayBuffer);
|
|
91
|
+
}
|
|
92
|
+
else if (typeof event.data === 'string') {
|
|
93
|
+
// JSON text data
|
|
94
|
+
frame = JSON.parse(event.data);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
throw new Error(`Unsupported message type: ${typeof event.data}`);
|
|
98
|
+
}
|
|
99
|
+
// fw parsed frame to store for entity updates
|
|
100
|
+
this.onFrame?.(frame);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
console.error('Failed to parse frame:', error);
|
|
104
|
+
this.updateState('error', 'Failed to parse frame from server');
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
// WebSocket error handling
|
|
108
|
+
this.ws.onerror = (error) => {
|
|
109
|
+
console.error('WebSocket error:', error);
|
|
110
|
+
this.updateState('error', 'WebSocket connection error'); // Notify store/UI of errors
|
|
111
|
+
};
|
|
112
|
+
// connection lost handler with auto-reconnection
|
|
113
|
+
this.ws.onclose = () => {
|
|
114
|
+
console.log('WebSocket disconnected');
|
|
115
|
+
this.stopPingInterval();
|
|
116
|
+
this.ws = null;
|
|
117
|
+
// only auto-reconnect if we didn't explicitly disconnect
|
|
118
|
+
// this preserves subscriptions across temporary network issues
|
|
119
|
+
if (this.currentState !== 'disconnected') {
|
|
120
|
+
this.handleReconnect();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
console.error('Failed to create WebSocket:', error);
|
|
126
|
+
this.updateState('error', 'Failed to create WebSocket connection');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
disconnect() {
|
|
130
|
+
this.clearReconnectTimeout();
|
|
131
|
+
this.stopPingInterval();
|
|
132
|
+
if (this.ws) {
|
|
133
|
+
this.ws.close();
|
|
134
|
+
this.ws = null;
|
|
135
|
+
}
|
|
136
|
+
this.updateState('disconnected');
|
|
137
|
+
}
|
|
138
|
+
updateConfig(newConfig) {
|
|
139
|
+
this.config = { ...this.config, ...newConfig };
|
|
140
|
+
}
|
|
141
|
+
subscribe(subscription) {
|
|
142
|
+
if (this.currentState === 'connected' && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
143
|
+
console.log('[Hyperstack] Subscribing to:', subscription.view);
|
|
144
|
+
const message = JSON.stringify(subscription);
|
|
145
|
+
this.ws.send(message);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
this.subscriptionQueue.push(subscription);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Unsubscribe support (feature-gated by server capabilities)
|
|
152
|
+
unsubscribe(_view, _key) {
|
|
153
|
+
if (this.config.supportsUnsubscribe && this.currentState === 'connected' && this.ws) {
|
|
154
|
+
// only send unsubscribe if server supports it - TO DO
|
|
155
|
+
console.warn('Unsubscribe not yet implemented on server side');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// binary frame parser - converts WebSocket binary data to EntityFrame
|
|
159
|
+
parseBinaryFrame(data) {
|
|
160
|
+
// server sends JSON Frame serialized as binary bytes
|
|
161
|
+
// convert binary data back to JSON string and parse
|
|
162
|
+
const decoder = new TextDecoder('utf-8');
|
|
163
|
+
const jsonString = decoder.decode(data);
|
|
164
|
+
// parse the Frame JSON sent by projector
|
|
165
|
+
const frame = JSON.parse(jsonString);
|
|
166
|
+
// convert to EntityFrame format expected by SDK
|
|
167
|
+
return {
|
|
168
|
+
mode: frame.mode,
|
|
169
|
+
entity: frame.entity, // Backend serializes view path as 'entity' field
|
|
170
|
+
op: frame.op,
|
|
171
|
+
key: frame.key,
|
|
172
|
+
data: frame.data
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
// Internal state change handler - notifies store and triggers UI re-renders
|
|
176
|
+
updateState(state, error) {
|
|
177
|
+
this.currentState = state;
|
|
178
|
+
this.onStateChange?.(state, error);
|
|
179
|
+
}
|
|
180
|
+
// Auto-reconnection with true exponential backoff protection
|
|
181
|
+
handleReconnect() {
|
|
182
|
+
const intervals = this.config.reconnectIntervals || [1000, 2000, 4000, 8000, 16000];
|
|
183
|
+
const maxAttempts = this.config.maxReconnectAttempts || intervals.length;
|
|
184
|
+
if (this.reconnectAttempts >= maxAttempts) {
|
|
185
|
+
// give up after max attempts to avoid infinite retry loops
|
|
186
|
+
this.updateState('error', `Max reconnection attempts (${this.reconnectAttempts}) reached`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.updateState('reconnecting'); // update store/UI to show reconnection status
|
|
190
|
+
// get delay for current attempt (use last interval if we exceed array length)
|
|
191
|
+
const attemptIndex = Math.min(this.reconnectAttempts, intervals.length - 1);
|
|
192
|
+
const delay = intervals[attemptIndex];
|
|
193
|
+
this.reconnectAttempts++;
|
|
194
|
+
// delayed reconnection with exponential backoff
|
|
195
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
196
|
+
console.log(`Reconnect attempt ${this.reconnectAttempts} after ${delay}ms delay`);
|
|
197
|
+
this.connect(); // recursive call - will restore subscriptions on success
|
|
198
|
+
}, delay);
|
|
199
|
+
}
|
|
200
|
+
// Cleanup helper for reconnection timer
|
|
201
|
+
clearReconnectTimeout() {
|
|
202
|
+
if (this.reconnectTimeout) {
|
|
203
|
+
clearTimeout(this.reconnectTimeout);
|
|
204
|
+
this.reconnectTimeout = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
startPingInterval() {
|
|
208
|
+
this.stopPingInterval();
|
|
209
|
+
this.pingInterval = setInterval(() => {
|
|
210
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
211
|
+
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
212
|
+
}
|
|
213
|
+
}, 15000);
|
|
214
|
+
}
|
|
215
|
+
stopPingInterval() {
|
|
216
|
+
if (this.pingInterval) {
|
|
217
|
+
clearInterval(this.pingInterval);
|
|
218
|
+
this.pingInterval = null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function deepMerge(target, source) {
|
|
224
|
+
if (!isObject(target) || !isObject(source)) {
|
|
225
|
+
return source;
|
|
226
|
+
}
|
|
227
|
+
const result = { ...target };
|
|
228
|
+
for (const key in source) {
|
|
229
|
+
const sourceValue = source[key];
|
|
230
|
+
const targetValue = result[key];
|
|
231
|
+
if (isObject(sourceValue) && isObject(targetValue)) {
|
|
232
|
+
result[key] = deepMerge(targetValue, sourceValue);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
result[key] = sourceValue;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
function isObject(item) {
|
|
241
|
+
return item && typeof item === 'object' && !Array.isArray(item);
|
|
242
|
+
}
|
|
243
|
+
function createHyperStore(config = {}) {
|
|
244
|
+
return create((set, get) => {
|
|
245
|
+
const connectionManager = new ConnectionManager({ ...DEFAULT_CONFIG, ...config });
|
|
246
|
+
const makeSubKey = (subscription) => `${subscription.view}:${subscription.key ?? '*'}:${subscription.partition ?? ''}:${JSON.stringify(subscription.filters ?? {})}`;
|
|
247
|
+
connectionManager.setHandlers({
|
|
248
|
+
onFrame: (frame) => {
|
|
249
|
+
get().handleFrame(frame);
|
|
250
|
+
},
|
|
251
|
+
onStateChange: (connectionState, error) => {
|
|
252
|
+
set({ connectionState, lastError: error });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
connectionState: 'disconnected',
|
|
257
|
+
lastError: undefined,
|
|
258
|
+
entities: new Map(),
|
|
259
|
+
recentFrames: [],
|
|
260
|
+
subscriptionRefs: new Map(),
|
|
261
|
+
connectionManager,
|
|
262
|
+
viewCache: {},
|
|
263
|
+
handleFrame: (frame) => {
|
|
264
|
+
set((state) => {
|
|
265
|
+
const newEntities = new Map(state.entities);
|
|
266
|
+
const viewPath = frame.entity;
|
|
267
|
+
const entityMap = new Map(newEntities.get(viewPath) || new Map());
|
|
268
|
+
switch (frame.op) {
|
|
269
|
+
case 'upsert':
|
|
270
|
+
entityMap.set(frame.key, frame.data);
|
|
271
|
+
break;
|
|
272
|
+
case 'patch':
|
|
273
|
+
const existing = entityMap.get(frame.key);
|
|
274
|
+
if (existing && typeof existing === 'object' && typeof frame.data === 'object') {
|
|
275
|
+
entityMap.set(frame.key, deepMerge(existing, frame.data));
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
entityMap.set(frame.key, frame.data);
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
case 'delete':
|
|
282
|
+
entityMap.delete(frame.key);
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
newEntities.set(viewPath, entityMap);
|
|
286
|
+
const newViewCache = { ...state.viewCache };
|
|
287
|
+
const currentMetadata = newViewCache[viewPath];
|
|
288
|
+
const keys = Array.from(entityMap.keys());
|
|
289
|
+
newViewCache[viewPath] = {
|
|
290
|
+
mode: (frame.mode === 'state' || frame.mode === 'list') ? frame.mode : 'list',
|
|
291
|
+
keys,
|
|
292
|
+
lastUpdatedAt: Date.now(),
|
|
293
|
+
lastArgs: currentMetadata?.lastArgs
|
|
294
|
+
};
|
|
295
|
+
return {
|
|
296
|
+
...state,
|
|
297
|
+
entities: newEntities,
|
|
298
|
+
recentFrames: [frame, ...state.recentFrames],
|
|
299
|
+
viewCache: newViewCache
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
_incRef: (subscription) => {
|
|
304
|
+
const subKey = makeSubKey(subscription);
|
|
305
|
+
const { subscriptionRefs } = get();
|
|
306
|
+
const existing = subscriptionRefs.get(subKey);
|
|
307
|
+
if (existing) {
|
|
308
|
+
existing.refCount++;
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
subscriptionRefs.set(subKey, {
|
|
312
|
+
subscription,
|
|
313
|
+
refCount: 1
|
|
314
|
+
});
|
|
315
|
+
connectionManager.subscribe(subscription);
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
_decRef: (subscription) => {
|
|
319
|
+
const subKey = makeSubKey(subscription);
|
|
320
|
+
const { subscriptionRefs } = get();
|
|
321
|
+
const existing = subscriptionRefs.get(subKey);
|
|
322
|
+
if (existing) {
|
|
323
|
+
existing.refCount--;
|
|
324
|
+
if (existing.refCount <= 0) {
|
|
325
|
+
subscriptionRefs.delete(subKey);
|
|
326
|
+
connectionManager.unsubscribe(subscription.view, subscription.key);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
_getRefCount: (subscription) => {
|
|
331
|
+
const subKey = makeSubKey(subscription);
|
|
332
|
+
return get().subscriptionRefs.get(subKey)?.refCount ?? 0;
|
|
333
|
+
},
|
|
334
|
+
subscribe: (subscription) => {
|
|
335
|
+
connectionManager.subscribe(subscription);
|
|
336
|
+
},
|
|
337
|
+
unsubscribe: (view, key) => {
|
|
338
|
+
connectionManager.unsubscribe(view, key);
|
|
339
|
+
},
|
|
340
|
+
connect: () => {
|
|
341
|
+
connectionManager.connect();
|
|
342
|
+
},
|
|
343
|
+
disconnect: () => {
|
|
344
|
+
connectionManager.disconnect();
|
|
345
|
+
},
|
|
346
|
+
updateConfig: (newConfig) => {
|
|
347
|
+
connectionManager.updateConfig(newConfig);
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function createRuntime(config) {
|
|
354
|
+
const store = createHyperStore({
|
|
355
|
+
websocketUrl: config.websocketUrl
|
|
356
|
+
});
|
|
357
|
+
const connection = store.getState().connectionManager;
|
|
358
|
+
return {
|
|
359
|
+
store,
|
|
360
|
+
connection,
|
|
361
|
+
wallet: config.wallet,
|
|
362
|
+
subscribe(view, key, filters) {
|
|
363
|
+
const subscription = { view, key, filters };
|
|
364
|
+
store.getState()._incRef(subscription);
|
|
365
|
+
return {
|
|
366
|
+
view,
|
|
367
|
+
key,
|
|
368
|
+
filters,
|
|
369
|
+
unsubscribe: () => store.getState()._decRef(subscription)
|
|
370
|
+
};
|
|
371
|
+
},
|
|
372
|
+
unsubscribe(handle) {
|
|
373
|
+
handle.unsubscribe();
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const HyperstackContext = createContext(null);
|
|
379
|
+
function resolveNetworkConfig(network, websocketUrl) {
|
|
380
|
+
if (websocketUrl) {
|
|
381
|
+
return {
|
|
382
|
+
name: 'custom',
|
|
383
|
+
websocketUrl
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (typeof network === 'object') {
|
|
387
|
+
return network;
|
|
388
|
+
}
|
|
389
|
+
if (network === 'mainnet') {
|
|
390
|
+
return {
|
|
391
|
+
name: 'mainnet',
|
|
392
|
+
websocketUrl: 'wss://mainnet.hyperstack.xyz',
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
if (network === 'devnet') {
|
|
396
|
+
return {
|
|
397
|
+
name: 'devnet',
|
|
398
|
+
websocketUrl: 'ws://localhost:8080',
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (network === 'localnet') {
|
|
402
|
+
return {
|
|
403
|
+
name: 'localnet',
|
|
404
|
+
websocketUrl: 'ws://localhost:8080',
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
throw new Error('Must provide either network or websocketUrl');
|
|
408
|
+
}
|
|
409
|
+
function HyperstackProvider({ children, ...config }) {
|
|
410
|
+
const networkConfig = useMemo(() => {
|
|
411
|
+
try {
|
|
412
|
+
return resolveNetworkConfig(config.network, config.websocketUrl);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
console.error('[Hyperstack] Invalid network configuration:', error);
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
}, [config.network, config.websocketUrl]);
|
|
419
|
+
const runtimeRef = useRef(null);
|
|
420
|
+
if (!runtimeRef.current) {
|
|
421
|
+
try {
|
|
422
|
+
runtimeRef.current = createRuntime({
|
|
423
|
+
...config,
|
|
424
|
+
websocketUrl: networkConfig.websocketUrl,
|
|
425
|
+
network: networkConfig
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
console.error('[Hyperstack] Failed to create runtime:', error);
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const runtime = runtimeRef.current;
|
|
434
|
+
const isMountedRef = useRef(true);
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
isMountedRef.current = true;
|
|
437
|
+
if (config.autoConnect !== false) {
|
|
438
|
+
try {
|
|
439
|
+
runtime.connection.connect();
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
console.error('[Hyperstack] Failed to auto-connect:', error);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return () => {
|
|
446
|
+
isMountedRef.current = false;
|
|
447
|
+
setTimeout(() => {
|
|
448
|
+
if (!isMountedRef.current) {
|
|
449
|
+
try {
|
|
450
|
+
runtime.connection.disconnect();
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
console.error('[Hyperstack] Failed to disconnect:', error);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}, 100);
|
|
457
|
+
};
|
|
458
|
+
}, [runtime, config.autoConnect]);
|
|
459
|
+
const value = {
|
|
460
|
+
runtime,
|
|
461
|
+
config: { ...config, network: networkConfig }
|
|
462
|
+
};
|
|
463
|
+
return (React.createElement(HyperstackContext.Provider, { value: value }, children));
|
|
464
|
+
}
|
|
465
|
+
function useHyperstackContext() {
|
|
466
|
+
const context = useContext(HyperstackContext);
|
|
467
|
+
if (!context) {
|
|
468
|
+
throw new Error('useHyperstackContext must be used within HyperstackProvider');
|
|
469
|
+
}
|
|
470
|
+
return context;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function createStateViewHook(viewDef, runtime) {
|
|
474
|
+
return {
|
|
475
|
+
use: (key, options) => {
|
|
476
|
+
const [isLoading, setIsLoading] = useState(!options?.initialData);
|
|
477
|
+
const [error, setError] = useState();
|
|
478
|
+
const keyString = Object.values(key)[0];
|
|
479
|
+
const enabled = options?.enabled !== false;
|
|
480
|
+
useEffect(() => {
|
|
481
|
+
if (!enabled)
|
|
482
|
+
return undefined;
|
|
483
|
+
try {
|
|
484
|
+
const handle = runtime.subscribe(viewDef.view, keyString);
|
|
485
|
+
setIsLoading(true);
|
|
486
|
+
return () => {
|
|
487
|
+
try {
|
|
488
|
+
handle.unsubscribe();
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
console.error('[Hyperstack] Error unsubscribing from view:', err);
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
setError(err instanceof Error ? err : new Error('Subscription failed'));
|
|
497
|
+
setIsLoading(false);
|
|
498
|
+
return undefined;
|
|
499
|
+
}
|
|
500
|
+
}, [keyString, enabled]);
|
|
501
|
+
const refresh = useCallback(() => {
|
|
502
|
+
if (!enabled)
|
|
503
|
+
return;
|
|
504
|
+
try {
|
|
505
|
+
const handle = runtime.subscribe(viewDef.view, keyString);
|
|
506
|
+
setIsLoading(true);
|
|
507
|
+
setTimeout(() => {
|
|
508
|
+
try {
|
|
509
|
+
handle.unsubscribe();
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
console.error('[Hyperstack] Error during refresh unsubscribe:', err);
|
|
513
|
+
}
|
|
514
|
+
}, 0);
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
setError(err instanceof Error ? err : new Error('Refresh failed'));
|
|
518
|
+
setIsLoading(false);
|
|
519
|
+
}
|
|
520
|
+
}, [keyString, enabled]);
|
|
521
|
+
const data = useSyncExternalStore((callback) => {
|
|
522
|
+
const unsubscribe = runtime.store.subscribe(() => {
|
|
523
|
+
callback();
|
|
524
|
+
});
|
|
525
|
+
return unsubscribe;
|
|
526
|
+
}, () => {
|
|
527
|
+
const rawData = runtime.store.getState().entities.get(viewDef.view)?.get(keyString);
|
|
528
|
+
if (rawData && viewDef.transform) {
|
|
529
|
+
try {
|
|
530
|
+
return viewDef.transform(rawData);
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return rawData;
|
|
537
|
+
});
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
if (data && isLoading) {
|
|
540
|
+
setIsLoading(false);
|
|
541
|
+
}
|
|
542
|
+
}, [data, isLoading]);
|
|
543
|
+
return {
|
|
544
|
+
data: options?.initialData ?? data,
|
|
545
|
+
isLoading,
|
|
546
|
+
error,
|
|
547
|
+
refresh
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function createListViewHook(viewDef, runtime) {
|
|
553
|
+
return {
|
|
554
|
+
use: (params, options) => {
|
|
555
|
+
const [isLoading, setIsLoading] = useState(!options?.initialData);
|
|
556
|
+
const [error, setError] = useState();
|
|
557
|
+
const cachedDataRef = useRef(undefined);
|
|
558
|
+
const lastMapRef = useRef(undefined);
|
|
559
|
+
const enabled = options?.enabled !== false;
|
|
560
|
+
const key = params?.key;
|
|
561
|
+
// Stabilize filters object to prevent unnecessary re-subscriptions
|
|
562
|
+
// We use a JSON string as the dependency to detect actual value changes
|
|
563
|
+
const filtersJson = params?.filters ? JSON.stringify(params.filters) : undefined;
|
|
564
|
+
const filters = useMemo(() => params?.filters, [filtersJson]);
|
|
565
|
+
useEffect(() => {
|
|
566
|
+
if (!enabled)
|
|
567
|
+
return undefined;
|
|
568
|
+
try {
|
|
569
|
+
const handle = runtime.subscribe(viewDef.view, key, filters);
|
|
570
|
+
setIsLoading(true);
|
|
571
|
+
return () => {
|
|
572
|
+
try {
|
|
573
|
+
handle.unsubscribe();
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
console.error('[Hyperstack] Error unsubscribing from list view:', err);
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
setError(err instanceof Error ? err : new Error('Subscription failed'));
|
|
582
|
+
setIsLoading(false);
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
}, [enabled, key, filtersJson]);
|
|
586
|
+
const refresh = useCallback(() => {
|
|
587
|
+
if (!enabled)
|
|
588
|
+
return;
|
|
589
|
+
try {
|
|
590
|
+
const handle = runtime.subscribe(viewDef.view, key, filters);
|
|
591
|
+
setIsLoading(true);
|
|
592
|
+
setTimeout(() => {
|
|
593
|
+
try {
|
|
594
|
+
handle.unsubscribe();
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
console.error('[Hyperstack] Error during list refresh unsubscribe:', err);
|
|
598
|
+
}
|
|
599
|
+
}, 0);
|
|
600
|
+
}
|
|
601
|
+
catch (err) {
|
|
602
|
+
setError(err instanceof Error ? err : new Error('Refresh failed'));
|
|
603
|
+
setIsLoading(false);
|
|
604
|
+
}
|
|
605
|
+
}, [enabled, key, filtersJson]);
|
|
606
|
+
const data = useSyncExternalStore((callback) => {
|
|
607
|
+
const unsubscribe = runtime.store.subscribe(() => {
|
|
608
|
+
callback();
|
|
609
|
+
});
|
|
610
|
+
return unsubscribe;
|
|
611
|
+
}, () => {
|
|
612
|
+
let baseMap = runtime.store.getState().entities.get(viewDef.view);
|
|
613
|
+
if (!baseMap) {
|
|
614
|
+
if (cachedDataRef.current !== undefined) {
|
|
615
|
+
cachedDataRef.current = undefined;
|
|
616
|
+
lastMapRef.current = undefined;
|
|
617
|
+
}
|
|
618
|
+
return undefined;
|
|
619
|
+
}
|
|
620
|
+
if (lastMapRef.current === baseMap && cachedDataRef.current !== undefined) {
|
|
621
|
+
return cachedDataRef.current;
|
|
622
|
+
}
|
|
623
|
+
let items = Array.from(baseMap.values()).map((value) => {
|
|
624
|
+
const item = value?.item ?? value;
|
|
625
|
+
if (viewDef.transform) {
|
|
626
|
+
try {
|
|
627
|
+
return viewDef.transform(item);
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
console.log("Error transforming", err);
|
|
631
|
+
return item;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return item;
|
|
635
|
+
});
|
|
636
|
+
if (params?.where) {
|
|
637
|
+
items = items.filter((item) => {
|
|
638
|
+
return Object.entries(params.where).every(([key, condition]) => {
|
|
639
|
+
const value = item[key];
|
|
640
|
+
if (typeof condition === 'object' && condition !== null) {
|
|
641
|
+
if ('gte' in condition)
|
|
642
|
+
return value >= condition.gte;
|
|
643
|
+
if ('lte' in condition)
|
|
644
|
+
return value <= condition.lte;
|
|
645
|
+
if ('gt' in condition)
|
|
646
|
+
return value > condition.gt;
|
|
647
|
+
if ('lt' in condition)
|
|
648
|
+
return value < condition.lt;
|
|
649
|
+
}
|
|
650
|
+
return value === condition;
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
if (params?.limit) {
|
|
655
|
+
items = items.slice(0, params.limit);
|
|
656
|
+
}
|
|
657
|
+
lastMapRef.current = runtime.store.getState().entities.get(viewDef.view);
|
|
658
|
+
cachedDataRef.current = items;
|
|
659
|
+
return items;
|
|
660
|
+
});
|
|
661
|
+
useEffect(() => {
|
|
662
|
+
if (data && isLoading) {
|
|
663
|
+
setIsLoading(false);
|
|
664
|
+
}
|
|
665
|
+
}, [data, isLoading]);
|
|
666
|
+
return {
|
|
667
|
+
data: options?.initialData ?? data,
|
|
668
|
+
isLoading,
|
|
669
|
+
error,
|
|
670
|
+
refresh
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function createTxMutationHook(runtime, transactions) {
|
|
677
|
+
return function useMutation() {
|
|
678
|
+
const [status, setStatus] = useState('idle');
|
|
679
|
+
const [error, setError] = useState();
|
|
680
|
+
const [signature, setSignature] = useState();
|
|
681
|
+
const submit = async (instructionOrTx) => {
|
|
682
|
+
setStatus('pending');
|
|
683
|
+
setError(undefined);
|
|
684
|
+
setSignature(undefined);
|
|
685
|
+
try {
|
|
686
|
+
if (!instructionOrTx) {
|
|
687
|
+
throw new Error('Transaction instruction or transaction object is required');
|
|
688
|
+
}
|
|
689
|
+
if (!runtime.wallet) {
|
|
690
|
+
throw new Error('Wallet not connected. Please provide a wallet adapter to HyperstackProvider.');
|
|
691
|
+
}
|
|
692
|
+
console.log('[Hyperstack] Submitting transaction:', instructionOrTx);
|
|
693
|
+
let txSignature;
|
|
694
|
+
let instructionsToRefresh = [];
|
|
695
|
+
if (Array.isArray(instructionOrTx)) {
|
|
696
|
+
console.log('[Hyperstack] Processing multiple instructions');
|
|
697
|
+
txSignature = await runtime.wallet.signAndSend(instructionOrTx);
|
|
698
|
+
instructionsToRefresh = instructionOrTx.filter(inst => inst && typeof inst === 'object' && inst.instruction && inst.params !== undefined);
|
|
699
|
+
}
|
|
700
|
+
else if (typeof instructionOrTx === 'object' &&
|
|
701
|
+
instructionOrTx.instruction &&
|
|
702
|
+
instructionOrTx.params !== undefined) {
|
|
703
|
+
console.log('[Hyperstack] Processing single instruction');
|
|
704
|
+
txSignature = await runtime.wallet.signAndSend(instructionOrTx);
|
|
705
|
+
instructionsToRefresh = [instructionOrTx];
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
console.log('[Hyperstack] Processing raw transaction');
|
|
709
|
+
txSignature = await runtime.wallet.signAndSend(instructionOrTx);
|
|
710
|
+
}
|
|
711
|
+
setSignature(txSignature);
|
|
712
|
+
setStatus('success');
|
|
713
|
+
if (transactions && instructionsToRefresh.length > 0) {
|
|
714
|
+
for (const inst of instructionsToRefresh) {
|
|
715
|
+
const txDef = transactions[inst.instruction];
|
|
716
|
+
if (txDef?.refresh) {
|
|
717
|
+
for (const refreshTarget of txDef.refresh) {
|
|
718
|
+
try {
|
|
719
|
+
const key = typeof refreshTarget.key === 'function'
|
|
720
|
+
? refreshTarget.key(inst.params)
|
|
721
|
+
: refreshTarget.key;
|
|
722
|
+
runtime.subscribe(refreshTarget.view, key);
|
|
723
|
+
}
|
|
724
|
+
catch (err) {
|
|
725
|
+
console.error('[Hyperstack] Error refreshing view after transaction:', err);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return txSignature;
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
const errorMessage = err instanceof Error ? err.message : 'Transaction failed';
|
|
735
|
+
console.error('[Hyperstack] Transaction error:', errorMessage, err);
|
|
736
|
+
setStatus('error');
|
|
737
|
+
setError(errorMessage);
|
|
738
|
+
throw err;
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
const reset = () => {
|
|
742
|
+
setStatus('idle');
|
|
743
|
+
setError(undefined);
|
|
744
|
+
setSignature(undefined);
|
|
745
|
+
};
|
|
746
|
+
return {
|
|
747
|
+
submit,
|
|
748
|
+
status,
|
|
749
|
+
error,
|
|
750
|
+
signature,
|
|
751
|
+
reset
|
|
752
|
+
};
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function defineStack(definition) {
|
|
757
|
+
if (!definition.name) {
|
|
758
|
+
throw new Error('[Hyperstack] Stack definition must have a name');
|
|
759
|
+
}
|
|
760
|
+
if (!definition.views || typeof definition.views !== 'object') {
|
|
761
|
+
throw new Error('[Hyperstack] Stack definition must have views');
|
|
762
|
+
}
|
|
763
|
+
return definition;
|
|
764
|
+
}
|
|
765
|
+
function useHyperstack(stack) {
|
|
766
|
+
if (!stack) {
|
|
767
|
+
throw new Error('[Hyperstack] Stack definition is required');
|
|
768
|
+
}
|
|
769
|
+
const { runtime } = useHyperstackContext();
|
|
770
|
+
const views = {};
|
|
771
|
+
try {
|
|
772
|
+
for (const [viewName, viewGroup] of Object.entries(stack.views)) {
|
|
773
|
+
views[viewName] = {};
|
|
774
|
+
if (typeof viewGroup === 'object' && viewGroup !== null) {
|
|
775
|
+
if ('state' in viewGroup && viewGroup.state) {
|
|
776
|
+
views[viewName].state = createStateViewHook(viewGroup.state, runtime);
|
|
777
|
+
}
|
|
778
|
+
if ('list' in viewGroup && viewGroup.list) {
|
|
779
|
+
views[viewName].list = createListViewHook(viewGroup.list, runtime);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
console.error('[Hyperstack] Error creating view hooks:', err);
|
|
786
|
+
throw err;
|
|
787
|
+
}
|
|
788
|
+
const tx = {};
|
|
789
|
+
try {
|
|
790
|
+
if (stack.transactions) {
|
|
791
|
+
for (const [txName, txDef] of Object.entries(stack.transactions)) {
|
|
792
|
+
tx[txName] = txDef.build;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
tx.useMutation = createTxMutationHook(runtime, stack.transactions);
|
|
796
|
+
}
|
|
797
|
+
catch (err) {
|
|
798
|
+
console.error('[Hyperstack] Error creating transaction hooks:', err);
|
|
799
|
+
tx.useMutation = () => ({
|
|
800
|
+
submit: async () => { },
|
|
801
|
+
status: 'idle',
|
|
802
|
+
error: 'Failed to initialize transaction hooks',
|
|
803
|
+
signature: undefined,
|
|
804
|
+
reset: () => { }
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
views,
|
|
809
|
+
tx,
|
|
810
|
+
helpers: stack.helpers || {},
|
|
811
|
+
store: runtime.store,
|
|
812
|
+
runtime
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function createStateView(viewPath, options) {
|
|
817
|
+
return {
|
|
818
|
+
mode: 'state',
|
|
819
|
+
view: viewPath,
|
|
820
|
+
type: {},
|
|
821
|
+
transform: options?.transform
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
function createListView(viewPath, options) {
|
|
825
|
+
return {
|
|
826
|
+
mode: 'list',
|
|
827
|
+
view: viewPath,
|
|
828
|
+
type: {},
|
|
829
|
+
transform: options?.transform
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export { ConnectionManager, DEFAULT_CONFIG, HyperStreamError, HyperstackProvider, createListView, createRuntime, createStateView, defineStack, useHyperstack, useHyperstackContext };
|
|
834
|
+
//# sourceMappingURL=index.esm.js.map
|