tekivex-ui 2.5.1 → 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.
- package/README.md +436 -83
- package/dist/TkxForm-DSMDo2Ol.js +388 -0
- package/dist/TkxForm-DwV_n-eE.cjs +12 -0
- package/dist/headless.cjs +1 -1
- package/dist/headless.js +396 -195
- package/dist/i18n.cjs +1 -0
- package/dist/i18n.js +757 -0
- package/dist/index.cjs +27 -22
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4994 -5089
- package/dist/quantum.cjs +91 -0
- package/dist/quantum.js +2312 -0
- package/dist/realtime.cjs +25 -0
- package/dist/realtime.js +944 -0
- package/dist/security-C369oOWH.cjs +1 -0
- package/dist/security-CzAmaMqa.js +97 -0
- package/dist/src/components/TkxDataGrid.d.ts +9 -1
- package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
- package/dist/src/components/TkxDropdown.d.ts +41 -0
- package/dist/src/components/TkxDropdown.d.ts.map +1 -0
- package/dist/src/components/TkxLiveFeed.d.ts +891 -0
- package/dist/src/components/TkxLiveFeed.d.ts.map +1 -0
- package/dist/src/components/TkxLiveLog.d.ts +892 -0
- package/dist/src/components/TkxLiveLog.d.ts.map +1 -0
- package/dist/src/components/TkxLiveMetrics.d.ts +24 -0
- package/dist/src/components/TkxLiveMetrics.d.ts.map +1 -0
- package/dist/src/components/TkxPlayground.d.ts +15 -0
- package/dist/src/components/TkxPlayground.d.ts.map +1 -0
- package/dist/src/components/TkxQuantumForm.d.ts +20 -0
- package/dist/src/components/TkxQuantumForm.d.ts.map +1 -0
- package/dist/src/components/TkxRealTimeChart.d.ts +27 -0
- package/dist/src/components/TkxRealTimeChart.d.ts.map +1 -0
- package/dist/src/components/TkxTable.d.ts +11 -1
- package/dist/src/components/TkxTable.d.ts.map +1 -1
- package/dist/src/components/TkxThemeBuilder.d.ts +9 -0
- package/dist/src/components/TkxThemeBuilder.d.ts.map +1 -0
- package/dist/src/components/index.d.ts +1 -0
- package/dist/src/components/index.d.ts.map +1 -1
- package/dist/src/engine/index.d.ts +2 -0
- package/dist/src/engine/index.d.ts.map +1 -1
- package/dist/src/engine/quantum-ai.d.ts +201 -0
- package/dist/src/engine/quantum-ai.d.ts.map +1 -0
- package/dist/src/engine/tkx.d.ts.map +1 -1
- package/dist/src/headless/index.d.ts +6 -0
- package/dist/src/headless/index.d.ts.map +1 -1
- package/dist/src/headless/useInfiniteQuery.d.ts +35 -0
- package/dist/src/headless/useInfiniteQuery.d.ts.map +1 -0
- package/dist/src/headless/useSSE.d.ts +36 -0
- package/dist/src/headless/useSSE.d.ts.map +1 -0
- package/dist/src/headless/useWebSocket.d.ts +33 -0
- package/dist/src/headless/useWebSocket.d.ts.map +1 -0
- package/dist/src/quantum/index.d.ts +9 -0
- package/dist/src/quantum/index.d.ts.map +1 -0
- package/dist/src/realtime/index.d.ts +9 -0
- package/dist/src/realtime/index.d.ts.map +1 -0
- package/dist/{TkxForm-BWK4LqY3.js → tkx-BJ10JtMv.js} +372 -721
- package/dist/tkx-BeNyC0w7.cjs +4 -0
- package/package.json +16 -1
- package/src/components/TkxDataGrid.tsx +72 -0
- package/src/components/TkxDropdown.tsx +802 -0
- package/src/components/TkxLiveFeed.tsx +329 -0
- package/src/components/TkxLiveLog.tsx +470 -0
- package/src/components/TkxLiveMetrics.tsx +291 -0
- package/src/components/TkxPlayground.tsx +736 -0
- package/src/components/TkxQuantumForm.tsx +592 -0
- package/src/components/TkxRealTimeChart.tsx +381 -0
- package/src/components/TkxTable.tsx +351 -77
- package/src/components/TkxThemeBuilder.tsx +685 -0
- package/src/components/index.ts +4 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/quantum-ai.ts +1045 -0
- package/src/engine/tkx.ts +95 -4
- package/src/headless/index.ts +6 -0
- package/src/headless/useInfiniteQuery.ts +153 -0
- package/src/headless/useSSE.ts +165 -0
- package/src/headless/useWebSocket.ts +229 -0
- package/src/quantum/index.ts +39 -0
- package/src/realtime/index.ts +21 -0
- 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
|
-
|
|
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)
|
|
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> = {
|
package/src/headless/index.ts
CHANGED
|
@@ -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
|
+
}
|