tekivex-ui 2.5.2 → 2.5.3

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.
Files changed (79) hide show
  1. package/dist/TkxForm-DSMDo2Ol.js +388 -0
  2. package/dist/TkxForm-DwV_n-eE.cjs +12 -0
  3. package/dist/headless.cjs +1 -1
  4. package/dist/headless.js +396 -195
  5. package/dist/i18n.cjs +1 -0
  6. package/dist/i18n.js +757 -0
  7. package/dist/index.cjs +27 -22
  8. package/dist/index.d.ts +2 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4994 -5089
  11. package/dist/quantum.cjs +91 -0
  12. package/dist/quantum.js +2312 -0
  13. package/dist/realtime.cjs +25 -0
  14. package/dist/realtime.js +944 -0
  15. package/dist/security-C369oOWH.cjs +1 -0
  16. package/dist/security-CzAmaMqa.js +97 -0
  17. package/dist/src/components/TkxDataGrid.d.ts +9 -1
  18. package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
  19. package/dist/src/components/TkxDropdown.d.ts +41 -0
  20. package/dist/src/components/TkxDropdown.d.ts.map +1 -0
  21. package/dist/src/components/TkxLiveFeed.d.ts +891 -0
  22. package/dist/src/components/TkxLiveFeed.d.ts.map +1 -0
  23. package/dist/src/components/TkxLiveLog.d.ts +892 -0
  24. package/dist/src/components/TkxLiveLog.d.ts.map +1 -0
  25. package/dist/src/components/TkxLiveMetrics.d.ts +24 -0
  26. package/dist/src/components/TkxLiveMetrics.d.ts.map +1 -0
  27. package/dist/src/components/TkxPlayground.d.ts +15 -0
  28. package/dist/src/components/TkxPlayground.d.ts.map +1 -0
  29. package/dist/src/components/TkxQuantumForm.d.ts +20 -0
  30. package/dist/src/components/TkxQuantumForm.d.ts.map +1 -0
  31. package/dist/src/components/TkxRealTimeChart.d.ts +27 -0
  32. package/dist/src/components/TkxRealTimeChart.d.ts.map +1 -0
  33. package/dist/src/components/TkxTable.d.ts +11 -1
  34. package/dist/src/components/TkxTable.d.ts.map +1 -1
  35. package/dist/src/components/TkxThemeBuilder.d.ts +9 -0
  36. package/dist/src/components/TkxThemeBuilder.d.ts.map +1 -0
  37. package/dist/src/components/index.d.ts +1 -0
  38. package/dist/src/components/index.d.ts.map +1 -1
  39. package/dist/src/engine/index.d.ts +2 -0
  40. package/dist/src/engine/index.d.ts.map +1 -1
  41. package/dist/src/engine/quantum-ai.d.ts +201 -0
  42. package/dist/src/engine/quantum-ai.d.ts.map +1 -0
  43. package/dist/src/engine/tkx.d.ts.map +1 -1
  44. package/dist/src/headless/index.d.ts +6 -0
  45. package/dist/src/headless/index.d.ts.map +1 -1
  46. package/dist/src/headless/useInfiniteQuery.d.ts +35 -0
  47. package/dist/src/headless/useInfiniteQuery.d.ts.map +1 -0
  48. package/dist/src/headless/useSSE.d.ts +36 -0
  49. package/dist/src/headless/useSSE.d.ts.map +1 -0
  50. package/dist/src/headless/useWebSocket.d.ts +33 -0
  51. package/dist/src/headless/useWebSocket.d.ts.map +1 -0
  52. package/dist/src/quantum/index.d.ts +9 -0
  53. package/dist/src/quantum/index.d.ts.map +1 -0
  54. package/dist/src/realtime/index.d.ts +9 -0
  55. package/dist/src/realtime/index.d.ts.map +1 -0
  56. package/dist/{TkxForm-BWK4LqY3.js → tkx-BJ10JtMv.js} +372 -721
  57. package/dist/tkx-BeNyC0w7.cjs +4 -0
  58. package/package.json +16 -1
  59. package/src/components/TkxDataGrid.tsx +72 -0
  60. package/src/components/TkxDropdown.tsx +802 -0
  61. package/src/components/TkxLiveFeed.tsx +329 -0
  62. package/src/components/TkxLiveLog.tsx +470 -0
  63. package/src/components/TkxLiveMetrics.tsx +291 -0
  64. package/src/components/TkxPlayground.tsx +736 -0
  65. package/src/components/TkxQuantumForm.tsx +592 -0
  66. package/src/components/TkxRealTimeChart.tsx +381 -0
  67. package/src/components/TkxTable.tsx +351 -77
  68. package/src/components/TkxThemeBuilder.tsx +685 -0
  69. package/src/components/index.ts +4 -0
  70. package/src/engine/index.ts +2 -0
  71. package/src/engine/quantum-ai.ts +1045 -0
  72. package/src/engine/tkx.ts +95 -4
  73. package/src/headless/index.ts +6 -0
  74. package/src/headless/useInfiniteQuery.ts +153 -0
  75. package/src/headless/useSSE.ts +165 -0
  76. package/src/headless/useWebSocket.ts +229 -0
  77. package/src/quantum/index.ts +39 -0
  78. package/src/realtime/index.ts +21 -0
  79. package/dist/TkxForm-ljQjX7KD.cjs +0 -15
package/src/engine/tkx.ts CHANGED
@@ -99,11 +99,95 @@ const BREAKPOINTS: Record<string, string> = {
99
99
  sm: '640px', md: '768px', lg: '1024px', xl: '1280px', '2xl': '1536px',
100
100
  };
101
101
 
102
+ // ── CSS Security Guards ───────────────────────────────────────────────────────
103
+ // Prevent CSS injection via arbitrary values. Blocks:
104
+ // • javascript: / vbscript: / data: URL schemes
105
+ // • CSS expression() (IE legacy code-exec vector)
106
+ // • -moz-binding / behavior (remote XBL/HTC execution)
107
+ // • Embedded newlines that break out of style blocks
108
+ //
109
+ // Quantum confidence: values that pass all guards score 1.0;
110
+ // values containing suspicious but non-blocked patterns score < 1.0
111
+ // and are stripped in strict mode.
112
+
113
+ /** Dangerous CSS value patterns — any match → reject the declaration. */
114
+ const DANGEROUS_VALUE_RE =
115
+ /javascript\s*:|vbscript\s*:|data\s*:\s*text\/html|data\s*:\s*text\/javascript|expression\s*\(|[\r\n\x00]/i;
116
+
117
+ /** Dangerous CSS property names. */
118
+ const DANGEROUS_PROPS = new Set([
119
+ 'behavior', '-moz-binding', '-webkit-binding', 'src',
120
+ ]);
121
+
122
+ /**
123
+ * Allowlist of CSS properties accepted via arbitrary [prop:value] shorthand.
124
+ * Anything not in this set is silently dropped.
125
+ */
126
+ const SAFE_CSS_PROPS = new Set([
127
+ // Box model
128
+ 'width','min-width','max-width','height','min-height','max-height',
129
+ 'margin','margin-top','margin-right','margin-bottom','margin-left',
130
+ 'padding','padding-top','padding-right','padding-bottom','padding-left',
131
+ // Positioning
132
+ 'position','top','right','bottom','left','z-index','inset',
133
+ // Flexbox / Grid
134
+ 'flex','flex-grow','flex-shrink','flex-basis','flex-direction','flex-wrap',
135
+ 'align-items','align-self','align-content','justify-content','justify-items','justify-self',
136
+ 'gap','row-gap','column-gap',
137
+ 'grid-template-columns','grid-template-rows','grid-column','grid-row',
138
+ 'grid-area','grid-template-areas',
139
+ // Typography
140
+ 'font-size','font-weight','font-family','font-style','line-height',
141
+ 'letter-spacing','text-align','text-decoration','text-transform',
142
+ 'text-overflow','white-space','word-break','word-wrap','overflow-wrap',
143
+ // Color / Background
144
+ 'color','background','background-color','background-image',
145
+ 'background-position','background-size','background-repeat',
146
+ 'opacity',
147
+ // Border
148
+ 'border','border-top','border-right','border-bottom','border-left',
149
+ 'border-width','border-style','border-color','border-radius',
150
+ 'outline','outline-offset',
151
+ // Effects
152
+ 'box-shadow','text-shadow','filter','backdrop-filter','transform',
153
+ 'transition','animation',
154
+ // Layout
155
+ 'display','overflow','overflow-x','overflow-y','visibility',
156
+ 'pointer-events','cursor','user-select',
157
+ // Misc safe
158
+ 'aspect-ratio','object-fit','object-position','list-style',
159
+ 'vertical-align','float','clear','resize','scroll-behavior',
160
+ 'content', // allow content: '' for pseudo-like patterns but value is guarded
161
+ ]);
162
+
163
+ /**
164
+ * Sanitize an arbitrary CSS value.
165
+ * Returns the value unchanged if safe, or null if dangerous.
166
+ */
167
+ function sanitizeCSSValue(value: string): string | null {
168
+ if (DANGEROUS_VALUE_RE.test(value)) return null;
169
+ // Strip any attempt to close a style block
170
+ if (value.includes('}') || value.includes('{') || value.includes('<') || value.includes('>')) return null;
171
+ return value;
172
+ }
173
+
174
+ /**
175
+ * Sanitize an arbitrary CSS property name.
176
+ * Returns the property unchanged if safe, or null if dangerous.
177
+ */
178
+ function sanitizeCSSProp(prop: string): string | null {
179
+ const normalized = prop.toLowerCase().trim();
180
+ if (DANGEROUS_PROPS.has(normalized)) return null;
181
+ if (!SAFE_CSS_PROPS.has(normalized)) return null; // allowlist enforcement
182
+ return normalized;
183
+ }
184
+
102
185
  // ── Arbitrary value extractor ─────────────────────────────────────────────────
103
186
 
104
187
  function extractArbitrary(value: string): string | null {
105
188
  const match = value.match(/^\[(.+)]$/);
106
- return match ? match[1] : null;
189
+ if (!match) return null;
190
+ return sanitizeCSSValue(match[1]);
107
191
  }
108
192
 
109
193
  // ── Color value resolver ──────────────────────────────────────────────────────
@@ -116,9 +200,11 @@ function resolveColor(token: string): string {
116
200
  if (token === 'white') return '#ffffff';
117
201
  if (token === 'black') return '#000000';
118
202
  if (token === 'inherit') return 'inherit';
119
- // Arbitrary: [#ff0000] or [rgb(0,0,0)]
203
+ // Arbitrary: [#ff0000] or [rgb(0,0,0)] — sanitized
120
204
  const arb = extractArbitrary(token);
121
205
  if (arb) return arb;
206
+ // Bare hex
207
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(token)) return token;
122
208
  return token;
123
209
  }
124
210
 
@@ -130,9 +216,14 @@ type Declarations = Record<string, string>;
130
216
 
131
217
  // eslint-disable-next-line complexity
132
218
  function resolveUtility(u: string): Declarations | null {
133
- // ── Arbitrary value shorthand: [css-prop:value] ────────────────────────────
219
+ // ── Arbitrary value shorthand: [css-prop:value] — security-guarded ────────
134
220
  const fullArb = u.match(/^\[([a-zA-Z-]+):(.+)]$/);
135
- if (fullArb) return { [fullArb[1]]: fullArb[2] };
221
+ if (fullArb) {
222
+ const safeProp = sanitizeCSSProp(fullArb[1]);
223
+ const safeVal = sanitizeCSSValue(fullArb[2]);
224
+ if (safeProp && safeVal) return { [safeProp]: safeVal };
225
+ return null; // silently drop dangerous declaration
226
+ }
136
227
 
137
228
  // ── Display ────────────────────────────────────────────────────────────────
138
229
  const displayMap: Record<string, string> = {
@@ -42,3 +42,9 @@ export { useControllable } from './useControllable';
42
42
  export { useMediaQuery } from './useMediaQuery';
43
43
  export { useLocalStorage } from './useLocalStorage';
44
44
  export { useIntersectionObserver } from './useIntersectionObserver';
45
+ export { useWebSocket } from './useWebSocket';
46
+ export type { WebSocketOptions, WebSocketState } from './useWebSocket';
47
+ export { useSSE } from './useSSE';
48
+ export type { SSEOptions, SSEState } from './useSSE';
49
+ export { useInfiniteQuery } from './useInfiniteQuery';
50
+ export type { InfiniteQueryOptions, InfiniteQueryState } from './useInfiniteQuery';
@@ -0,0 +1,153 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+
3
+ export interface InfiniteQueryOptions<T, C = unknown> {
4
+ queryFn: (cursor: C | null, pageIndex: number) => Promise<{ data: T[]; nextCursor: C | null; hasMore: boolean }>;
5
+ initialCursor?: C | null;
6
+ pageSize?: number;
7
+ enabled?: boolean;
8
+ dedupKey?: string;
9
+ }
10
+
11
+ export interface InfiniteQueryState<T, C = unknown> {
12
+ data: T[];
13
+ pages: T[][];
14
+ status: 'idle' | 'loading' | 'success' | 'error' | 'fetching-more';
15
+ error: Error | null;
16
+ hasMore: boolean;
17
+ isFetchingMore: boolean;
18
+ fetchMore: () => void;
19
+ reset: () => void;
20
+ pageIndex: number;
21
+ totalLoaded: number;
22
+ }
23
+
24
+ /**
25
+ * Headless infinite-scroll / pagination hook with cursor-based querying,
26
+ * dedup protection, and safe unmount cleanup.
27
+ *
28
+ * @example
29
+ * const query = useInfiniteQuery({
30
+ * queryFn: async (cursor) => fetchPosts({ cursor }),
31
+ * });
32
+ * // In your component: query.data, query.fetchMore(), query.hasMore
33
+ */
34
+ export function useInfiniteQuery<T, C = unknown>(
35
+ options: InfiniteQueryOptions<T, C>,
36
+ ): InfiniteQueryState<T, C> {
37
+ const {
38
+ queryFn,
39
+ initialCursor = null,
40
+ enabled = true,
41
+ dedupKey,
42
+ } = options;
43
+
44
+ const [pages, setPages] = useState<T[][]>([]);
45
+ const [status, setStatus] = useState<InfiniteQueryState<T, C>['status']>('idle');
46
+ const [error, setError] = useState<Error | null>(null);
47
+ const [hasMore, setHasMore] = useState(false);
48
+ const [pageIndex, setPageIndex] = useState(0);
49
+
50
+ const cursorRef = useRef<C | null>(initialCursor ?? null);
51
+ const isFetchingRef = useRef(false);
52
+ const mountedRef = useRef(true);
53
+ const lastDedupKeyRef = useRef<string | undefined>(undefined);
54
+
55
+ const queryFnRef = useRef(queryFn);
56
+ queryFnRef.current = queryFn;
57
+
58
+ const loadPage = useCallback(
59
+ async (cursor: C | null, index: number, isFirstPage: boolean) => {
60
+ if (isFetchingRef.current) return;
61
+
62
+ // Dedup: skip if same key as last completed fetch
63
+ if (dedupKey !== undefined && dedupKey === lastDedupKeyRef.current) return;
64
+
65
+ isFetchingRef.current = true;
66
+ let aborted = false;
67
+
68
+ if (mountedRef.current) {
69
+ setStatus(isFirstPage ? 'loading' : 'fetching-more');
70
+ setError(null);
71
+ }
72
+
73
+ try {
74
+ const result = await queryFnRef.current(cursor, index);
75
+
76
+ if (aborted || !mountedRef.current) return;
77
+
78
+ if (dedupKey !== undefined) {
79
+ lastDedupKeyRef.current = dedupKey;
80
+ }
81
+
82
+ cursorRef.current = result.nextCursor;
83
+
84
+ setPages(prev => {
85
+ if (isFirstPage) return [result.data];
86
+ return [...prev, result.data];
87
+ });
88
+ setHasMore(result.hasMore);
89
+ setPageIndex(index);
90
+ setStatus('success');
91
+ } catch (err) {
92
+ if (aborted || !mountedRef.current) return;
93
+ setError(err instanceof Error ? err : new Error(String(err)));
94
+ setStatus('error');
95
+ } finally {
96
+ if (!aborted) isFetchingRef.current = false;
97
+ }
98
+
99
+ // Return cleanup so callers can signal abort
100
+ return () => { aborted = true; };
101
+ },
102
+ // dedupKey intentionally included to allow re-checking on each call
103
+ [dedupKey],
104
+ );
105
+
106
+ const fetchMore = useCallback(() => {
107
+ if (!hasMore || isFetchingRef.current) return;
108
+ void loadPage(cursorRef.current, pageIndex + 1, false);
109
+ }, [hasMore, pageIndex, loadPage]);
110
+
111
+ const reset = useCallback(() => {
112
+ isFetchingRef.current = false;
113
+ lastDedupKeyRef.current = undefined;
114
+ cursorRef.current = initialCursor ?? null;
115
+ setPages([]);
116
+ setStatus('idle');
117
+ setError(null);
118
+ setHasMore(false);
119
+ setPageIndex(0);
120
+ }, [initialCursor]);
121
+
122
+ // Initial load
123
+ useEffect(() => {
124
+ mountedRef.current = true;
125
+
126
+ if (enabled) {
127
+ void loadPage(initialCursor ?? null, 0, true);
128
+ }
129
+
130
+ return () => {
131
+ mountedRef.current = false;
132
+ };
133
+ // Only run on mount / when enabled changes
134
+ // eslint-disable-next-line react-hooks/exhaustive-deps
135
+ }, [enabled]);
136
+
137
+ const data = pages.flat();
138
+ const isFetchingMore = status === 'fetching-more';
139
+ const totalLoaded = data.length;
140
+
141
+ return {
142
+ data,
143
+ pages,
144
+ status,
145
+ error,
146
+ hasMore,
147
+ isFetchingMore,
148
+ fetchMore,
149
+ reset,
150
+ pageIndex,
151
+ totalLoaded,
152
+ };
153
+ }
@@ -0,0 +1,165 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+
3
+ export interface SSEOptions {
4
+ url: string;
5
+ events?: string[];
6
+ withCredentials?: boolean;
7
+ reconnect?: boolean;
8
+ reconnectDelay?: number;
9
+ onOpen?: () => void;
10
+ onError?: (error: Event) => void;
11
+ }
12
+
13
+ export interface SSEState<T = unknown> {
14
+ status: 'connecting' | 'connected' | 'error' | 'closed';
15
+ lastEvent: { type: string; data: T; id?: string } | null;
16
+ events: Array<{ type: string; data: T; id?: string; timestamp: number }>;
17
+ connect: () => void;
18
+ disconnect: () => void;
19
+ eventCount: number;
20
+ }
21
+
22
+ /**
23
+ * Headless Server-Sent Events hook supporting multiple named event types,
24
+ * auto-reconnect, and JSON auto-parsing.
25
+ *
26
+ * @example
27
+ * const sse = useSSE({ url: '/api/stream', events: ['update', 'notification'] });
28
+ * console.log(sse.lastEvent);
29
+ */
30
+ export function useSSE<T = unknown>(options: SSEOptions): SSEState<T> {
31
+ const {
32
+ url,
33
+ events = ['message'],
34
+ withCredentials = false,
35
+ reconnect = true,
36
+ reconnectDelay = 3000,
37
+ onOpen,
38
+ onError,
39
+ } = options;
40
+
41
+ const [status, setStatus] = useState<SSEState<T>['status']>('connecting');
42
+ const [lastEvent, setLastEvent] = useState<SSEState<T>['lastEvent']>(null);
43
+ const [eventList, setEventList] = useState<SSEState<T>['events']>([]);
44
+ const [eventCount, setEventCount] = useState(0);
45
+
46
+ const esRef = useRef<EventSource | null>(null);
47
+ const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
48
+ const mountedRef = useRef(true);
49
+ const manualCloseRef = useRef(false);
50
+
51
+ const onOpenRef = useRef(onOpen);
52
+ const onErrorRef = useRef(onError);
53
+ onOpenRef.current = onOpen;
54
+ onErrorRef.current = onError;
55
+
56
+ const clearReconnectTimer = useCallback(() => {
57
+ if (reconnectTimerRef.current) {
58
+ clearTimeout(reconnectTimerRef.current);
59
+ reconnectTimerRef.current = null;
60
+ }
61
+ }, []);
62
+
63
+ const connect = useCallback(() => {
64
+ if (typeof EventSource === 'undefined') {
65
+ if (mountedRef.current) setStatus('error');
66
+ return;
67
+ }
68
+
69
+ manualCloseRef.current = false;
70
+ clearReconnectTimer();
71
+
72
+ if (esRef.current) {
73
+ esRef.current.close();
74
+ esRef.current = null;
75
+ }
76
+
77
+ if (mountedRef.current) setStatus('connecting');
78
+
79
+ const es = new EventSource(url, { withCredentials });
80
+ esRef.current = es;
81
+
82
+ es.onopen = () => {
83
+ if (!mountedRef.current) return;
84
+ setStatus('connected');
85
+ onOpenRef.current?.();
86
+ };
87
+
88
+ es.onerror = (event) => {
89
+ if (!mountedRef.current) return;
90
+ setStatus('error');
91
+ onErrorRef.current?.(event);
92
+
93
+ if (!manualCloseRef.current && reconnect) {
94
+ clearReconnectTimer();
95
+ reconnectTimerRef.current = setTimeout(() => {
96
+ if (mountedRef.current) connect();
97
+ }, reconnectDelay);
98
+ }
99
+ };
100
+
101
+ const handleEvent = (type: string) => (raw: MessageEvent) => {
102
+ if (!mountedRef.current) return;
103
+
104
+ let parsed: T;
105
+ try {
106
+ parsed = JSON.parse(raw.data as string) as T;
107
+ } catch {
108
+ parsed = raw.data as unknown as T;
109
+ }
110
+
111
+ const entry: SSEState<T>['events'][number] = {
112
+ type,
113
+ data: parsed,
114
+ timestamp: Date.now(),
115
+ ...(raw.lastEventId ? { id: raw.lastEventId } : {}),
116
+ };
117
+
118
+ setLastEvent({ type: entry.type, data: entry.data, id: entry.id });
119
+ setEventList(prev => {
120
+ const next = [...prev, entry];
121
+ return next.length > 200 ? next.slice(next.length - 200) : next;
122
+ });
123
+ setEventCount(c => c + 1);
124
+ };
125
+
126
+ for (const eventType of events) {
127
+ es.addEventListener(eventType, handleEvent(eventType) as EventListener);
128
+ }
129
+ // eslint-disable-next-line react-hooks/exhaustive-deps
130
+ }, [url, withCredentials, reconnect, reconnectDelay, clearReconnectTimer]);
131
+
132
+ const disconnect = useCallback(() => {
133
+ manualCloseRef.current = true;
134
+ clearReconnectTimer();
135
+ if (esRef.current) {
136
+ esRef.current.close();
137
+ esRef.current = null;
138
+ }
139
+ if (mountedRef.current) setStatus('closed');
140
+ }, [clearReconnectTimer]);
141
+
142
+ useEffect(() => {
143
+ mountedRef.current = true;
144
+ connect();
145
+ return () => {
146
+ mountedRef.current = false;
147
+ manualCloseRef.current = true;
148
+ clearReconnectTimer();
149
+ if (esRef.current) {
150
+ esRef.current.close();
151
+ esRef.current = null;
152
+ }
153
+ };
154
+ // eslint-disable-next-line react-hooks/exhaustive-deps
155
+ }, []);
156
+
157
+ return {
158
+ status,
159
+ lastEvent,
160
+ events: eventList,
161
+ connect,
162
+ disconnect,
163
+ eventCount,
164
+ };
165
+ }
@@ -0,0 +1,229 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+
3
+ export interface WebSocketOptions {
4
+ url: string;
5
+ protocols?: string | string[];
6
+ reconnect?: boolean;
7
+ reconnectDelay?: number;
8
+ reconnectAttempts?: number;
9
+ heartbeatInterval?: number;
10
+ heartbeatMessage?: string;
11
+ onOpen?: (event: Event) => void;
12
+ onClose?: (event: CloseEvent) => void;
13
+ onError?: (event: Event) => void;
14
+ onMessage?: (data: unknown) => void;
15
+ }
16
+
17
+ export interface WebSocketState<T = unknown> {
18
+ status: 'connecting' | 'connected' | 'disconnected' | 'error';
19
+ lastMessage: T | null;
20
+ messages: T[];
21
+ send: (data: string | object) => void;
22
+ connect: () => void;
23
+ disconnect: () => void;
24
+ reconnectCount: number;
25
+ latency: number | null;
26
+ }
27
+
28
+ /**
29
+ * Headless WebSocket hook with auto-reconnect, heartbeat, latency tracking,
30
+ * and a send queue that flushes on connect.
31
+ *
32
+ * @example
33
+ * const ws = useWebSocket({ url: 'wss://example.com/ws' });
34
+ * ws.send({ type: 'subscribe', channel: 'updates' });
35
+ */
36
+ export function useWebSocket<T = unknown>(options: WebSocketOptions): WebSocketState<T> {
37
+ const {
38
+ url,
39
+ protocols,
40
+ reconnect = true,
41
+ reconnectDelay = 1000,
42
+ reconnectAttempts = 10,
43
+ heartbeatInterval = 30000,
44
+ heartbeatMessage = 'ping',
45
+ onOpen,
46
+ onClose,
47
+ onError,
48
+ onMessage,
49
+ } = options;
50
+
51
+ const [status, setStatus] = useState<WebSocketState<T>['status']>('disconnected');
52
+ const [lastMessage, setLastMessage] = useState<T | null>(null);
53
+ const [messages, setMessages] = useState<T[]>([]);
54
+ const [reconnectCount, setReconnectCount] = useState(0);
55
+ const [latency, setLatency] = useState<number | null>(null);
56
+
57
+ const wsRef = useRef<WebSocket | null>(null);
58
+ const reconnectCountRef = useRef(0);
59
+ const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
60
+ const heartbeatTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
61
+ const pingTimeRef = useRef<number | null>(null);
62
+ const sendQueueRef = useRef<Array<string | object>>([]);
63
+ const mountedRef = useRef(true);
64
+ const manualDisconnectRef = useRef(false);
65
+
66
+ // Keep option callbacks in refs so connect() closure stays stable
67
+ const onOpenRef = useRef(onOpen);
68
+ const onCloseRef = useRef(onClose);
69
+ const onErrorRef = useRef(onError);
70
+ const onMessageRef = useRef(onMessage);
71
+ onOpenRef.current = onOpen;
72
+ onCloseRef.current = onClose;
73
+ onErrorRef.current = onError;
74
+ onMessageRef.current = onMessage;
75
+
76
+ const clearTimers = useCallback(() => {
77
+ if (reconnectTimerRef.current) {
78
+ clearTimeout(reconnectTimerRef.current);
79
+ reconnectTimerRef.current = null;
80
+ }
81
+ if (heartbeatTimerRef.current) {
82
+ clearInterval(heartbeatTimerRef.current);
83
+ heartbeatTimerRef.current = null;
84
+ }
85
+ }, []);
86
+
87
+ const flushQueue = useCallback((ws: WebSocket) => {
88
+ while (sendQueueRef.current.length > 0) {
89
+ const msg = sendQueueRef.current.shift();
90
+ if (msg !== undefined) {
91
+ ws.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
92
+ }
93
+ }
94
+ }, []);
95
+
96
+ const connect = useCallback(() => {
97
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
98
+
99
+ manualDisconnectRef.current = false;
100
+ clearTimers();
101
+
102
+ if (mountedRef.current) setStatus('connecting');
103
+
104
+ const ws = new WebSocket(url, protocols);
105
+ wsRef.current = ws;
106
+
107
+ ws.onopen = (event) => {
108
+ if (!mountedRef.current) return;
109
+ setStatus('connected');
110
+ reconnectCountRef.current = 0;
111
+ setReconnectCount(0);
112
+ onOpenRef.current?.(event);
113
+ flushQueue(ws);
114
+
115
+ // Start heartbeat
116
+ heartbeatTimerRef.current = setInterval(() => {
117
+ if (ws.readyState === WebSocket.OPEN) {
118
+ pingTimeRef.current = Date.now();
119
+ try {
120
+ ws.send(JSON.stringify({ type: 'ping', t: pingTimeRef.current }));
121
+ } catch {
122
+ // If send fails the error handler will manage reconnect
123
+ }
124
+ }
125
+ }, heartbeatInterval);
126
+ };
127
+
128
+ ws.onmessage = (event) => {
129
+ if (!mountedRef.current) return;
130
+
131
+ let parsed: T;
132
+ try {
133
+ parsed = JSON.parse(event.data as string) as T;
134
+ } catch {
135
+ parsed = event.data as unknown as T;
136
+ }
137
+
138
+ // Latency tracking: respond to pong
139
+ const asRecord = parsed as Record<string, unknown>;
140
+ if (
141
+ asRecord &&
142
+ typeof asRecord === 'object' &&
143
+ asRecord.type === 'pong' &&
144
+ typeof asRecord.t === 'number'
145
+ ) {
146
+ setLatency(Date.now() - asRecord.t);
147
+ return;
148
+ }
149
+
150
+ // Also measure latency if message text matches heartbeatMessage
151
+ if (parsed === heartbeatMessage && pingTimeRef.current !== null) {
152
+ setLatency(Date.now() - pingTimeRef.current);
153
+ pingTimeRef.current = null;
154
+ return;
155
+ }
156
+
157
+ setLastMessage(parsed);
158
+ setMessages(prev => {
159
+ const next = [...prev, parsed];
160
+ return next.length > 100 ? next.slice(next.length - 100) : next;
161
+ });
162
+ onMessageRef.current?.(parsed);
163
+ };
164
+
165
+ ws.onerror = (event) => {
166
+ if (!mountedRef.current) return;
167
+ setStatus('error');
168
+ onErrorRef.current?.(event);
169
+ };
170
+
171
+ ws.onclose = (event) => {
172
+ if (!mountedRef.current) return;
173
+ clearTimers();
174
+ setStatus('disconnected');
175
+ onCloseRef.current?.(event);
176
+
177
+ if (
178
+ !manualDisconnectRef.current &&
179
+ reconnect &&
180
+ reconnectCountRef.current < reconnectAttempts
181
+ ) {
182
+ const attempt = reconnectCountRef.current;
183
+ const delay = reconnectDelay * Math.pow(1.5, attempt);
184
+ reconnectCountRef.current += 1;
185
+ setReconnectCount(reconnectCountRef.current);
186
+ reconnectTimerRef.current = setTimeout(() => {
187
+ if (mountedRef.current) connect();
188
+ }, delay);
189
+ }
190
+ };
191
+ // eslint-disable-next-line react-hooks/exhaustive-deps
192
+ }, [url, protocols, reconnect, reconnectDelay, reconnectAttempts, heartbeatInterval, heartbeatMessage, clearTimers, flushQueue]);
193
+
194
+ const disconnect = useCallback(() => {
195
+ manualDisconnectRef.current = true;
196
+ clearTimers();
197
+ if (wsRef.current) {
198
+ wsRef.current.close();
199
+ wsRef.current = null;
200
+ }
201
+ if (mountedRef.current) setStatus('disconnected');
202
+ }, [clearTimers]);
203
+
204
+ const send = useCallback((data: string | object) => {
205
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
206
+ wsRef.current.send(typeof data === 'string' ? data : JSON.stringify(data));
207
+ } else {
208
+ sendQueueRef.current.push(data);
209
+ }
210
+ }, []);
211
+
212
+ useEffect(() => {
213
+ mountedRef.current = true;
214
+ connect();
215
+ return () => {
216
+ mountedRef.current = false;
217
+ manualDisconnectRef.current = true;
218
+ clearTimers();
219
+ if (wsRef.current) {
220
+ wsRef.current.close();
221
+ wsRef.current = null;
222
+ }
223
+ };
224
+ // connect is stable (useCallback with stable deps), so this is intentional
225
+ // eslint-disable-next-line react-hooks/exhaustive-deps
226
+ }, []);
227
+
228
+ return { status, lastMessage, messages, send, connect, disconnect, reconnectCount, latency };
229
+ }