@fynixorg/ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,678 @@
1
+ /**
2
+ * Fynix File-Based Router - PRODUCTION FIXED VERSION
3
+ * All Security & Memory Leak Issues Fixed
4
+ */
5
+
6
+ import { mount } from "../runtime.js";
7
+
8
+ const MAX_CACHE_SIZE = 50;
9
+ const PROPS_NAMESPACE = '__fynixLinkProps__';
10
+ const MAX_LISTENERS = 100;
11
+ const ALLOWED_PROTOCOLS = ['http:', 'https:', ''];
12
+
13
+ // FIX 1: Singleton pattern to prevent multiple router instances
14
+ let routerInstance = null;
15
+ let isRouterInitialized = false;
16
+
17
+ /**
18
+ * Security: Improved HTML escaping to prevent XSS
19
+ */
20
+ function escapeHTML(str) {
21
+ if (typeof str !== 'string') return '';
22
+ return str
23
+ .replace(/&/g, '&')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ .replace(/'/g, '&#039;')
28
+ .replace(/`/g, '&#96;')
29
+ .replace(/\//g, '&#x2F;');
30
+ }
31
+
32
+ /**
33
+ * Security: Validate URL to prevent open redirect
34
+ */
35
+ function isValidURL(url) {
36
+ try {
37
+ const parsed = new URL(url, window.location.origin);
38
+
39
+ if (parsed.origin !== window.location.origin) {
40
+ console.warn('[Router] Security: Cross-origin navigation blocked');
41
+ return false;
42
+ }
43
+
44
+ if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
45
+ console.warn('[Router] Security: Dangerous protocol blocked:', parsed.protocol);
46
+ return false;
47
+ }
48
+
49
+ return true;
50
+ } catch (e) {
51
+ console.warn('[Router] Security: Invalid URL blocked');
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Security: Sanitize path to prevent directory traversal
58
+ */
59
+ function sanitizePath(path) {
60
+ if (typeof path !== 'string') return '/';
61
+
62
+ // Decode URL encoding first to catch encoded traversal attempts like %2e%2e
63
+ try {
64
+ path = decodeURIComponent(path);
65
+ } catch (e) {
66
+ // Invalid encoding, reject
67
+ console.warn('[Router] Invalid URL encoding in path');
68
+ return '/';
69
+ }
70
+
71
+ path = path.replace(/\0/g, '');
72
+ path = path.replace(/\\/g, '/');
73
+ path = path.replace(/\/+/g, '/');
74
+ path = path.split('/').filter(part => part !== '..' && part !== '.').join('/');
75
+
76
+ if (!path.startsWith('/')) {
77
+ path = '/' + path;
78
+ }
79
+
80
+ if (path.length > 1 && path.endsWith('/')) {
81
+ path = path.slice(0, -1);
82
+ }
83
+
84
+ return path || '/';
85
+ }
86
+
87
+ /**
88
+ * Helper: Try multiple possible glob paths for file-based routing
89
+ */
90
+ function tryGlobPaths() {
91
+ try {
92
+ // @ts-ignore - Vite glob API
93
+ const modules = import.meta.glob("/src/**/*.{ts,js,jsx,fnx}", { eager: true });
94
+ return modules || {};
95
+ } catch (error) {
96
+ console.error('[Router] Failed to load modules:', error);
97
+ return {};
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Convert file path to route path
103
+ */
104
+ function filePathToRoute(filePath) {
105
+ let route = filePath
106
+ .replace(/^.*\/src/, "")
107
+ .replace(/\.(js|jsx|fnx)$/, "")
108
+ .replace(/\/view$/, "")
109
+ .replace(/\/$/, "");
110
+
111
+ if (!route) route = "/";
112
+ route = route.replace(/\[([^\]]+)\]/g, ":$1");
113
+ return route;
114
+ }
115
+
116
+ /**
117
+ * Match a dynamic route pattern
118
+ */
119
+ function matchDynamicRoute(path, dynamicRoutes) {
120
+ for (const route of dynamicRoutes) {
121
+ const match = path.match(route.regex);
122
+ if (match) {
123
+ const params = {};
124
+ route.params.forEach((param, i) => {
125
+ // FIX: Don't decode again - already decoded in sanitizePath
126
+ // Just escape the matched value
127
+ params[param] = escapeHTML(match[i + 1]);
128
+ });
129
+ return { component: route.component, params };
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Deserialize plain props
137
+ */
138
+ function deserializeProps(props) {
139
+ if (!props || typeof props !== 'object') return {};
140
+
141
+ const deserialized = {};
142
+ for (const [key, value] of Object.entries(props)) {
143
+ if (typeof key !== 'string' || key.startsWith('__')) {
144
+ continue;
145
+ }
146
+ deserialized[key] = value;
147
+ }
148
+ return deserialized;
149
+ }
150
+
151
+ /**
152
+ * Normalize path
153
+ */
154
+ function normalizePath(path) {
155
+ return sanitizePath(path);
156
+ }
157
+
158
+ /**
159
+ * FIX 2: Generate unique cache keys using crypto API when available
160
+ */
161
+ function generateCacheKey() {
162
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
163
+ return crypto.randomUUID();
164
+ }
165
+ // Fallback with better uniqueness
166
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
167
+ }
168
+
169
+ /**
170
+ * @typedef {Object} FynixRouter
171
+ * @property {function(string=): void} mountRouter - Mount router to DOM element
172
+ * @property {function(string, Object=): void} navigate - Navigate to path with props
173
+ * @property {function(string, Object=): void} replace - Replace current path
174
+ * @property {function(): void} back - Navigate back
175
+ * @property {function(): void} cleanup - Cleanup router instance
176
+ * @property {Object} routes - Static routes map
177
+ * @property {Array} dynamicRoutes - Dynamic routes array
178
+ */
179
+
180
+ /**
181
+ * Fynix Router Factory
182
+ * @returns {FynixRouter}
183
+ */
184
+ export default function createFynix() {
185
+ // FIX 3: Singleton pattern - return existing instance if already initialized
186
+ // Skip singleton check in dev mode (HMR) to allow hot reloading
187
+ const isDevMode = import.meta.hot !== undefined;
188
+
189
+ if (routerInstance && isRouterInitialized && !isDevMode) {
190
+ console.warn('[Router] Router already initialized, returning existing instance');
191
+ return routerInstance;
192
+ }
193
+
194
+ // In dev mode with HMR, cleanup old instance before creating new one
195
+ if (isDevMode && routerInstance) {
196
+ console.log('[Router] HMR: Cleaning up old router instance');
197
+ routerInstance.cleanup();
198
+ routerInstance = null;
199
+ isRouterInitialized = false;
200
+ }
201
+
202
+ let rootSelector = "#app-root";
203
+ let currentPath = null;
204
+ let isDestroyed = false;
205
+ let listenerCount = 0;
206
+
207
+ const listeners = [];
208
+
209
+ if (!window[PROPS_NAMESPACE]) {
210
+ window[PROPS_NAMESPACE] = {};
211
+ }
212
+
213
+ // Clear old cache in dev mode to prevent memory buildup
214
+ if (isDevMode && window.__fynixPropsCache) {
215
+ window.__fynixPropsCache.clear();
216
+ }
217
+
218
+ // @ts-ignore - Custom cache property
219
+ const __fynixPropsCache = window.__fynixPropsCache || new Map();
220
+ // @ts-ignore
221
+ window.__fynixPropsCache = __fynixPropsCache;
222
+
223
+ const modules = tryGlobPaths();
224
+ const routes = {};
225
+ const dynamicRoutes = [];
226
+
227
+ for (const [filePath, mod] of Object.entries(modules)) {
228
+ const routePath = filePathToRoute(filePath);
229
+ const component = mod.default || mod[Object.keys(mod)[0]] || Object.values(mod)[0];
230
+
231
+ if (!component) continue;
232
+
233
+ const hasDynamic = /:[^/]+/.test(routePath);
234
+ if (hasDynamic) {
235
+ dynamicRoutes.push({
236
+ pattern: routePath,
237
+ regex: new RegExp("^" + routePath.replace(/:[^/]+/g, "([^/]+)") + "$"),
238
+ component,
239
+ params: [...routePath.matchAll(/:([^/]+)/g)].map((m) => m[1]),
240
+ });
241
+ } else {
242
+ routes[routePath] = component;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Add cache management with LRU
248
+ */
249
+ function addToCache(key, value) {
250
+ if (__fynixPropsCache.size >= MAX_CACHE_SIZE) {
251
+ const firstKey = __fynixPropsCache.keys().next().value;
252
+ const evicted = __fynixPropsCache.get(firstKey);
253
+
254
+ if (evicted && typeof evicted === 'object') {
255
+ Object.values(evicted).forEach(val => {
256
+ if (val && typeof val === 'object' && val.cleanup) {
257
+ try { val.cleanup(); } catch (e) {}
258
+ }
259
+ });
260
+ }
261
+
262
+ __fynixPropsCache.delete(firstKey);
263
+ }
264
+ __fynixPropsCache.set(key, value);
265
+ }
266
+
267
+ const MANAGED_META = [
268
+ { key: "description", name: "description" },
269
+ { key: "keywords", name: "keywords" },
270
+ { key: "twitterCard", name: "twitter:card" },
271
+ { key: "ogTitle", property: "og:title" },
272
+ { key: "ogDescription", property: "og:description" },
273
+ { key: "ogImage", property: "og:image" },
274
+ ];
275
+
276
+ /**
277
+ * Update document meta tags for SEO with XSS prevention
278
+ * @param {Object} meta - Meta object
279
+ */
280
+ function updateMetaTags(meta = {}) {
281
+ if (!meta || typeof meta !== 'object') return;
282
+
283
+ if (meta.title && typeof meta.title === 'string') {
284
+ document.title = escapeHTML(meta.title);
285
+ }
286
+
287
+ MANAGED_META.forEach(def => {
288
+ const value = meta[def.key];
289
+
290
+ const selector = def.name
291
+ ? `meta[name="${def.name}"]`
292
+ : `meta[property="${def.property}"]`;
293
+
294
+ let el = document.querySelector(selector);
295
+
296
+ if (value == null) {
297
+ if (el) el.remove();
298
+ return;
299
+ }
300
+
301
+ if (typeof value !== 'string') return;
302
+
303
+ if (!el) {
304
+ el = document.createElement("meta");
305
+ if (def.name) el.setAttribute("name", def.name);
306
+ if (def.property) el.setAttribute("property", def.property);
307
+ document.head.appendChild(el);
308
+ }
309
+
310
+ el.setAttribute("content", escapeHTML(value));
311
+ });
312
+ }
313
+
314
+ // FIX 4: Debounce renderRoute to prevent race conditions
315
+ let renderTimeout = null;
316
+ const RENDER_DEBOUNCE = 10; // ms
317
+
318
+ /**
319
+ * Core route rendering function
320
+ */
321
+ function renderRoute() {
322
+ if (isDestroyed) return;
323
+
324
+ // FIX 5: Debounce to prevent race conditions
325
+ if (renderTimeout) {
326
+ clearTimeout(renderTimeout);
327
+ }
328
+
329
+ renderTimeout = setTimeout(() => {
330
+ _renderRouteImmediate();
331
+ renderTimeout = null;
332
+ }, RENDER_DEBOUNCE);
333
+ }
334
+
335
+ function _renderRouteImmediate() {
336
+ if (isDestroyed) return;
337
+
338
+ const path = normalizePath(window.location.pathname);
339
+ let Page = routes[path];
340
+ let params = {};
341
+ let routeProps = {};
342
+
343
+ if (!Page) {
344
+ const match = matchDynamicRoute(path, dynamicRoutes);
345
+ if (match) {
346
+ Page = match.component;
347
+ params = match.params;
348
+ }
349
+ }
350
+
351
+ const root = document.querySelector(rootSelector);
352
+ if (!root) {
353
+ console.error("[Router] Root element not found:", rootSelector);
354
+ return;
355
+ }
356
+
357
+ if (!Page) {
358
+ root.innerHTML = `<h2>404 Not Found</h2><p>Path: ${escapeHTML(path)}</p>`;
359
+ updateMetaTags({ title: "404 - Page Not Found" });
360
+ return;
361
+ }
362
+
363
+ const state = window.history.state || {};
364
+ let passedProps = {};
365
+
366
+ if (state.__fynixCacheKey && __fynixPropsCache.has(state.__fynixCacheKey)) {
367
+ passedProps = __fynixPropsCache.get(state.__fynixCacheKey);
368
+ } else if (state.serializedProps) {
369
+ passedProps = deserializeProps(state.serializedProps);
370
+ }
371
+
372
+ if (Page.props) {
373
+ routeProps = typeof Page.props === "function" ? Page.props() : Page.props;
374
+ }
375
+
376
+ if (Page.meta) {
377
+ const meta = typeof Page.meta === "function" ? Page.meta(params) : Page.meta;
378
+ updateMetaTags(meta);
379
+ }
380
+
381
+ // @ts-ignore
382
+ window.__lastRouteProps = {
383
+ ...routeProps,
384
+ ...passedProps,
385
+ params,
386
+ };
387
+
388
+ try {
389
+ mount(Page, rootSelector, false, window.__lastRouteProps);
390
+ } catch (err) {
391
+ console.error("[Router] Mount failed:", err);
392
+ root.innerHTML = `<pre style="color:red;">Mount Error occurred</pre>`;
393
+ }
394
+
395
+ currentPath = path;
396
+ }
397
+
398
+ /**
399
+ * SPA Navigation Helpers
400
+ */
401
+ function navigate(path, props = {}) {
402
+ if (isDestroyed) return;
403
+
404
+ path = normalizePath(path);
405
+
406
+ if (!isValidURL(window.location.origin + path)) {
407
+ console.error('[Router] Invalid navigation URL');
408
+ return;
409
+ }
410
+
411
+ if (path === currentPath) return;
412
+
413
+ const cacheKey = generateCacheKey();
414
+ addToCache(cacheKey, props);
415
+
416
+ try {
417
+ window.history.pushState({ __fynixCacheKey: cacheKey }, "", path);
418
+ renderRoute();
419
+ } catch (err) {
420
+ console.error('[Router] Navigation failed:', err);
421
+ }
422
+ }
423
+
424
+ function replace(path, props = {}) {
425
+ if (isDestroyed) return;
426
+
427
+ path = normalizePath(path);
428
+
429
+ if (!isValidURL(window.location.origin + path)) {
430
+ console.error('[Router] Invalid replace URL');
431
+ return;
432
+ }
433
+
434
+ const cacheKey = generateCacheKey();
435
+ addToCache(cacheKey, props);
436
+
437
+ try {
438
+ window.history.replaceState({ __fynixCacheKey: cacheKey }, "", path);
439
+ renderRoute();
440
+ } catch (err) {
441
+ console.error('[Router] Replace failed:', err);
442
+ }
443
+ }
444
+
445
+ function back() {
446
+ if (isDestroyed) return;
447
+ try {
448
+ window.history.back();
449
+ } catch (err) {
450
+ console.error('[Router] Back navigation failed:', err);
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Mount the router to a DOM element
456
+ */
457
+ function mountRouter(selector = "#app-root") {
458
+ if (isDestroyed) {
459
+ console.error("[Router] Cannot mount destroyed router");
460
+ return;
461
+ }
462
+
463
+ if (typeof selector !== 'string' || selector.length === 0) {
464
+ console.error('[Router] Invalid selector');
465
+ return;
466
+ }
467
+
468
+ rootSelector = selector;
469
+ renderRoute();
470
+ isRouterInitialized = true;
471
+ }
472
+
473
+ /**
474
+ * Link click delegation
475
+ */
476
+ const clickHandler = (e) => {
477
+ if (isDestroyed) return;
478
+
479
+ const link = e.target.closest("a[data-fynix-link]");
480
+ if (!link) return;
481
+
482
+ const href = link.getAttribute('href');
483
+ if (!href) {
484
+ console.warn('[Router] Missing href attribute');
485
+ return;
486
+ }
487
+
488
+ // FIX: Build full URL for validation (handles relative URLs)
489
+ const fullUrl = new URL(link.href, window.location.origin).href;
490
+ if (!isValidURL(fullUrl)) {
491
+ console.warn('[Router] Invalid link href');
492
+ return;
493
+ }
494
+
495
+ e.preventDefault();
496
+
497
+ const path = normalizePath(new URL(link.href, window.location.origin).pathname);
498
+
499
+ if (path === currentPath) return;
500
+
501
+ let props = {};
502
+ const propsKey = link.getAttribute("data-props-key");
503
+
504
+ if (propsKey && typeof propsKey === 'string' && !propsKey.startsWith('__')) {
505
+ if (window[PROPS_NAMESPACE]?.[propsKey]) {
506
+ props = window[PROPS_NAMESPACE][propsKey];
507
+ }
508
+ }
509
+
510
+ const serializableProps = {};
511
+ for (const [k, v] of Object.entries(props)) {
512
+ if (typeof k !== 'string' || k.startsWith('__')) continue;
513
+ serializableProps[k] = v && (v._isNixState || v._isRestState) ? v.value : v;
514
+ }
515
+
516
+ const cacheKey = generateCacheKey();
517
+ addToCache(cacheKey, serializableProps);
518
+
519
+ try {
520
+ window.history.pushState(
521
+ { __fynixCacheKey: cacheKey, serializedProps: serializableProps },
522
+ "",
523
+ path
524
+ );
525
+ renderRoute();
526
+ } catch (err) {
527
+ console.error('[Router] Link navigation failed:', err);
528
+ }
529
+ };
530
+
531
+ // FIX 6: Only add listeners if not already added
532
+ if (listenerCount < MAX_LISTENERS && !isRouterInitialized) {
533
+ document.addEventListener("click", clickHandler);
534
+ listeners.push({ element: document, event: "click", handler: clickHandler });
535
+ listenerCount++;
536
+
537
+ window.addEventListener("popstate", renderRoute);
538
+ listeners.push({ element: window, event: "popstate", handler: renderRoute });
539
+ listenerCount++;
540
+ }
541
+
542
+ /**
543
+ * Cleanup function
544
+ */
545
+ function cleanup() {
546
+ // FIX: Clear timeout FIRST to prevent pending renders
547
+ if (renderTimeout) {
548
+ clearTimeout(renderTimeout);
549
+ renderTimeout = null;
550
+ }
551
+
552
+ // THEN mark as destroyed
553
+ isDestroyed = true;
554
+
555
+ // Remove all event listeners
556
+ listeners.forEach(({ element, event, handler }) => {
557
+ try {
558
+ element.removeEventListener(event, handler);
559
+ } catch (e) {
560
+ console.error('[Router] Cleanup error:', e);
561
+ }
562
+ });
563
+ listeners.length = 0;
564
+ listenerCount = 0;
565
+
566
+ // Clean up all cached props
567
+ __fynixPropsCache.forEach(props => {
568
+ if (props && typeof props === 'object') {
569
+ Object.values(props).forEach(val => {
570
+ if (val && typeof val === 'object' && val.cleanup) {
571
+ try { val.cleanup(); } catch (e) {}
572
+ }
573
+ });
574
+ }
575
+ });
576
+ __fynixPropsCache.clear();
577
+
578
+ // Clean up global namespace
579
+ if (window[PROPS_NAMESPACE]) {
580
+ Object.keys(window[PROPS_NAMESPACE]).forEach(key => {
581
+ delete window[PROPS_NAMESPACE][key];
582
+ });
583
+ delete window[PROPS_NAMESPACE];
584
+ }
585
+
586
+ // Clear last route props
587
+ // @ts-ignore
588
+ if (window.__lastRouteProps) {
589
+ // @ts-ignore
590
+ delete window.__lastRouteProps;
591
+ }
592
+
593
+ // Reset singleton flags at the VERY end
594
+ isRouterInitialized = false;
595
+ routerInstance = null;
596
+
597
+ console.log("[Router] Cleanup complete");
598
+ }
599
+
600
+ // @ts-ignore - Vite HMR API
601
+ if (import.meta.hot) {
602
+ // @ts-ignore
603
+ import.meta.hot.accept(() => {
604
+ console.log("[Router] HMR detected, re-rendering route...");
605
+ renderRoute();
606
+ });
607
+
608
+ // @ts-ignore
609
+ import.meta.hot.dispose(() => {
610
+ console.log("[Router] HMR dispose, cleaning up...");
611
+ cleanup();
612
+ // Reset singleton flags for HMR
613
+ routerInstance = null;
614
+ isRouterInitialized = false;
615
+ });
616
+ }
617
+
618
+ const router = {
619
+ mountRouter,
620
+ navigate,
621
+ replace,
622
+ back,
623
+ cleanup,
624
+ routes,
625
+ dynamicRoutes,
626
+ };
627
+
628
+ routerInstance = router;
629
+ return router;
630
+ }
631
+
632
+ /**
633
+ * Helper: Set props for links
634
+ */
635
+ export function setLinkProps(key, props) {
636
+ if (typeof key !== 'string' || key.startsWith('__')) {
637
+ console.error('[Router] Invalid props key');
638
+ return;
639
+ }
640
+
641
+ if (!props || typeof props !== 'object') {
642
+ console.error('[Router] Invalid props object');
643
+ return;
644
+ }
645
+
646
+ if (!window[PROPS_NAMESPACE]) {
647
+ window[PROPS_NAMESPACE] = {};
648
+ }
649
+
650
+ if (Object.keys(window[PROPS_NAMESPACE]).length >= MAX_CACHE_SIZE) {
651
+ console.warn('[Router] Props storage limit reached');
652
+ return;
653
+ }
654
+
655
+ window[PROPS_NAMESPACE][key] = props;
656
+ }
657
+
658
+ /**
659
+ * Helper: Clear link props
660
+ */
661
+ export function clearLinkProps(key) {
662
+ if (typeof key !== 'string') return;
663
+
664
+ if (window[PROPS_NAMESPACE]?.[key]) {
665
+ const props = window[PROPS_NAMESPACE][key];
666
+ if (props && typeof props === 'object') {
667
+ Object.values(props).forEach(val => {
668
+ if (val && typeof val === 'object' && val.cleanup) {
669
+ try { val.cleanup(); } catch (e) {}
670
+ }
671
+ });
672
+ }
673
+ delete window[PROPS_NAMESPACE][key];
674
+ }
675
+ }
676
+
677
+ // Named export for better IDE support
678
+ export { createFynix };