violit 0.0.1__py3-none-any.whl

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.
violit/broadcast.py ADDED
@@ -0,0 +1,690 @@
1
+ """WebSocket-based real-time broadcasting system"""
2
+
3
+ import asyncio
4
+ import threading
5
+ from typing import Dict, Any, Callable, Optional, List
6
+ import json
7
+ import uuid
8
+
9
+ from .broadcast_primitives import UI_PRIMITIVES, validate_primitive
10
+
11
+
12
+ class Broadcaster:
13
+ """WebSocket-based real-time broadcasting system"""
14
+
15
+ def __init__(self, app):
16
+ self.app = app
17
+ self._bindings: Dict[str, Dict] = {}
18
+ self._primitives = UI_PRIMITIVES
19
+ self._router_injected = False
20
+
21
+ # Core: Event Broadcasting
22
+
23
+ def get_active_sessions(self) -> list:
24
+ if not hasattr(self.app, 'ws_engine') or not self.app.ws_engine:
25
+ return []
26
+ return list(self.app.ws_engine.sockets.keys())
27
+
28
+ async def _broadcast_eval_async(self, js_code: str, exclude_session: Optional[str] = None):
29
+ if not hasattr(self.app, 'ws_engine') or not self.app.ws_engine:
30
+ print("[BROADCAST] WebSocket engine not available.")
31
+ return
32
+
33
+ active_sids = self.get_active_sessions()
34
+
35
+ if exclude_session:
36
+ active_sids = [sid for sid in active_sids if sid != exclude_session]
37
+
38
+ print(f"[BROADCAST] Starting: {len(active_sids)} sessions")
39
+
40
+ success_count = 0
41
+ for sid in active_sids:
42
+ try:
43
+ await self.app.ws_engine.push_eval(sid, js_code)
44
+ success_count += 1
45
+ except Exception as e:
46
+ print(f"[BROADCAST] Failed for session {sid[:8]}...: {e}")
47
+
48
+ print(f"[BROADCAST] Completed: {success_count}/{len(active_sids)} successful")
49
+
50
+ def eval_all(self, js_code: str, exclude_current: bool = False):
51
+ """Send JavaScript code to all clients (low-level API)
52
+
53
+ Args:
54
+ js_code: JavaScript code to execute
55
+ exclude_current: Exclude current session
56
+ """
57
+ from .context import session_ctx
58
+
59
+ exclude_session = None
60
+ if exclude_current:
61
+ try:
62
+ exclude_session = session_ctx.get()
63
+ except:
64
+ pass
65
+
66
+ def run_broadcast():
67
+ try:
68
+ loop = asyncio.new_event_loop()
69
+ asyncio.set_event_loop(loop)
70
+ loop.run_until_complete(self._broadcast_eval_async(js_code, exclude_session))
71
+ loop.close()
72
+ except Exception as e:
73
+ print(f"[BROADCAST] Execution failed: {e}")
74
+
75
+ thread = threading.Thread(target=run_broadcast, daemon=True)
76
+ thread.start()
77
+
78
+ def broadcast_event(self, event_name: str, data: Dict[str, Any],
79
+ exclude_current: bool = False):
80
+ """Broadcast domain event to all clients
81
+
82
+ Args:
83
+ event_name: Event name (e.g. 'post_added', 'user_joined')
84
+ data: Event data (must be JSON-serializable)
85
+ exclude_current: Exclude current session
86
+ """
87
+ # Auto-generate event ID for deduplication
88
+ if '_eventId' not in data:
89
+ data['_eventId'] = str(uuid.uuid4())
90
+
91
+ data_json = json.dumps(data)
92
+
93
+ js_code = f"""
94
+ (function() {{
95
+ const event = new CustomEvent('{event_name}', {{
96
+ detail: {data_json}
97
+ }});
98
+ window.dispatchEvent(event);
99
+ console.log('🔔 Event received: {event_name}');
100
+ }})();
101
+ """
102
+
103
+ self.eval_all(js_code, exclude_current=exclude_current)
104
+
105
+ # Widget-Level Bindings
106
+
107
+ def bind_list(self,
108
+ list_key: str,
109
+ on_append: str = None,
110
+ on_remove: str = None,
111
+ on_update: str = None,
112
+ on_replace: str = None) -> None:
113
+ """Register event bindings for list widget
114
+
115
+ Args:
116
+ list_key: List widget key
117
+ on_append: Event name to trigger append
118
+ on_remove: Event name to trigger remove
119
+ on_update: Event name to trigger update
120
+ on_replace: Event name to trigger replace_all
121
+ """
122
+ if on_append:
123
+ self._register_binding(on_append, {
124
+ 'type': 'list.append',
125
+ 'params': {
126
+ 'list_key': list_key,
127
+ 'item_data': 'e.detail',
128
+ 'position': 'prepend'
129
+ }
130
+ })
131
+
132
+ if on_remove:
133
+ self._register_binding(on_remove, {
134
+ 'type': 'list.remove',
135
+ 'params': {
136
+ 'list_key': list_key,
137
+ 'item_id': 'e.detail.id || e.detail.post_id'
138
+ }
139
+ })
140
+
141
+ if on_update:
142
+ self._register_binding(on_update, {
143
+ 'type': 'list.update',
144
+ 'params': {
145
+ 'list_key': list_key,
146
+ 'item_id': 'e.detail.id',
147
+ 'item_data': 'e.detail'
148
+ }
149
+ })
150
+
151
+ if on_replace:
152
+ self._register_binding(on_replace, {
153
+ 'type': 'list.replace_all',
154
+ 'params': {
155
+ 'list_key': list_key,
156
+ 'items': 'e.detail.items || e.detail'
157
+ }
158
+ })
159
+
160
+ def bind_state(self,
161
+ state_key: str,
162
+ on_set: str = None,
163
+ on_increment: str = None,
164
+ on_decrement: str = None) -> None:
165
+ """Register event bindings for state
166
+
167
+ Args:
168
+ state_key: State key
169
+ on_set: Event name to trigger set
170
+ on_increment: Event name to trigger increment
171
+ on_decrement: Event name to trigger decrement
172
+ """
173
+ if on_set:
174
+ self._register_binding(on_set, {
175
+ 'type': 'state.set',
176
+ 'params': {
177
+ 'state_key': state_key,
178
+ 'value': 'e.detail.value || e.detail'
179
+ }
180
+ })
181
+
182
+ if on_increment:
183
+ self._register_binding(on_increment, {
184
+ 'type': 'state.increment',
185
+ 'params': {
186
+ 'state_key': state_key,
187
+ 'amount': 'e.detail.amount || 1'
188
+ }
189
+ })
190
+
191
+ if on_decrement:
192
+ self._register_binding(on_decrement, {
193
+ 'type': 'state.decrement',
194
+ 'params': {
195
+ 'state_key': state_key,
196
+ 'amount': 'e.detail.amount || 1'
197
+ }
198
+ })
199
+
200
+ def bind_feedback(self,
201
+ on_toast: str = None,
202
+ message_expr: str = None,
203
+ variant: str = 'neutral',
204
+ duration: int = 3000) -> None:
205
+ """Register feedback (toast) binding
206
+
207
+ Args:
208
+ on_toast: Event name to trigger toast
209
+ message_expr: Message JS expression (default: 'e.detail.message')
210
+ variant: Toast variant
211
+ duration: Display duration (ms)
212
+ """
213
+ if on_toast:
214
+ self._register_binding(on_toast, {
215
+ 'type': 'feedback.toast',
216
+ 'params': {
217
+ 'message': message_expr or 'e.detail.message',
218
+ 'variant': f"'{variant}'",
219
+ 'duration': duration
220
+ }
221
+ })
222
+
223
+ # App-Level Bindings
224
+
225
+ def bind_event(self,
226
+ event_name: str,
227
+ primitives: List[Dict[str, Any]],
228
+ dedupe: bool = True) -> None:
229
+ """Register app-level event binding (multiple primitives)
230
+
231
+ Use when one event affects multiple UI elements
232
+
233
+ Args:
234
+ event_name: Event name
235
+ primitives: List of primitives to execute
236
+ dedupe: Prevent duplicate events (default: True)
237
+ """
238
+ for primitive in primitives:
239
+ # Validate primitive
240
+ valid, msg = validate_primitive(primitive)
241
+ if not valid:
242
+ print(f"[BROADCAST] Warning: Invalid primitive for event '{event_name}': {msg}")
243
+ continue
244
+
245
+ self._register_binding(event_name, primitive)
246
+
247
+ # Set metadata
248
+ if dedupe:
249
+ if event_name not in self._bindings:
250
+ self._bindings[event_name] = {'primitives': [], 'meta': {}}
251
+ self._bindings[event_name]['meta']['dedupe'] = True
252
+
253
+ # ========== Internal: Binding Registry ==========
254
+
255
+ def _register_binding(self, event_name: str, primitive: Dict):
256
+ """Register binding in registry (internal)"""
257
+ if event_name not in self._bindings:
258
+ self._bindings[event_name] = {
259
+ 'primitives': [],
260
+ 'meta': {'dedupe': True} # Default: prevent duplicates
261
+ }
262
+ self._bindings[event_name]['primitives'].append(primitive)
263
+
264
+ def get_bindings(self) -> Dict:
265
+ """Return currently registered bindings (for debugging)"""
266
+ return self._bindings
267
+
268
+ # ========== Router & Helpers Injection ==========
269
+
270
+ def register_js_helpers(self) -> str:
271
+ """
272
+ Register client-side JavaScript helper functions
273
+
274
+ Provides safe DOM manipulation functions for XSS protection.
275
+ Call once at page startup.
276
+
277
+ Returns:
278
+ <script> tag containing JavaScript helper functions
279
+ """
280
+ return """
281
+ <script>
282
+ // ========== Violit JS Helpers (XSS-Safe) ==========
283
+
284
+ window.violit = window.violit || {};
285
+
286
+ // HTML escape (XSS protection)
287
+ window.violit.escapeHtml = (text) => {
288
+ if (!text) return '';
289
+ const div = document.createElement('div');
290
+ div.textContent = text;
291
+ return div.innerHTML;
292
+ };
293
+
294
+ // Convert newlines to <br> (safe)
295
+ window.violit.nl2br = (text) => {
296
+ return window.violit.escapeHtml(text).replace(/\\n/g, '<br>');
297
+ };
298
+
299
+ // Create Live Card (same structure as Python styled_card)
300
+ window.violit.createLiveCard = (data) => {
301
+ // Create wrapper div (layout consistency)
302
+ const wrapper = document.createElement('div');
303
+ wrapper.style.cssText = 'width: 100%;';
304
+
305
+ const card = document.createElement('sl-card');
306
+ card.setAttribute('data-post-id', data.id);
307
+ card.style.cssText = 'width: 100%;';
308
+
309
+ // Header - LIVE badge only (same as Python styled_card)
310
+ const headerWrapper = document.createElement('div');
311
+ headerWrapper.setAttribute('slot', 'header');
312
+
313
+ const headerInner = document.createElement('div');
314
+ headerInner.style.cssText = 'display: flex; gap: 0.5rem; align-items: center;';
315
+
316
+ const badge = document.createElement('sl-badge');
317
+ badge.setAttribute('variant', 'danger');
318
+ badge.setAttribute('pulse', '');
319
+ badge.innerHTML = '<sl-icon name="circle-fill" style="font-size: 0.5rem;"></sl-icon> LIVE';
320
+
321
+ headerInner.appendChild(badge);
322
+ headerWrapper.appendChild(headerInner);
323
+
324
+ // Body - content (XSS protection: use escapeHtml)
325
+ const body = document.createElement('div');
326
+ body.style.cssText = 'font-size: 1.1rem; line-height: 1.6; white-space: pre-wrap;';
327
+ body.textContent = data.content; // textContent auto-escapes
328
+
329
+ // Footer - timestamp (same as Python styled_card)
330
+ const footerWrapper = document.createElement('div');
331
+ footerWrapper.setAttribute('slot', 'footer');
332
+
333
+ const footerInner = document.createElement('div');
334
+ footerInner.style.cssText = 'text-align: right; font-size: 0.85rem; color: var(--sl-color-neutral-600);';
335
+ footerInner.innerHTML = `<sl-icon name="clock"></sl-icon> ${window.violit.escapeHtml(data.created_at)}`;
336
+
337
+ footerWrapper.appendChild(footerInner);
338
+
339
+ card.appendChild(headerWrapper);
340
+ card.appendChild(body);
341
+ card.appendChild(footerWrapper);
342
+
343
+ wrapper.appendChild(card);
344
+
345
+ return wrapper; // Return wrapper
346
+ };
347
+
348
+ console.log('✅ Violit helpers loaded (XSS-safe)');
349
+ </script>
350
+ """
351
+
352
+ def inject_all(self) -> None:
353
+ """
354
+ Auto-inject helpers + router into HTML template (convenience method)
355
+
356
+ This method calls both register_js_helpers() and inject_router()
357
+ to automatically inject into HTML template.
358
+
359
+ Call once at page startup or in setup function.
360
+
361
+ Example:
362
+ def setup_bindings():
363
+ # ... binding setup ...
364
+ app.broadcaster.inject_all() # Auto-inject
365
+ """
366
+ if self._router_injected:
367
+ print("[BROADCAST] Warning: Scripts already injected!")
368
+ return
369
+
370
+ helpers_script = self.register_js_helpers()
371
+ router_script = self.inject_router()
372
+
373
+ # Modify HTML_TEMPLATE in violit.app module
374
+ from . import app as vl_app_module
375
+ vl_app_module.HTML_TEMPLATE = vl_app_module.HTML_TEMPLATE.replace(
376
+ '</body>',
377
+ f'{helpers_script}{router_script}</body>'
378
+ )
379
+
380
+ print("[BROADCAST] [OK] Helpers and Router injected into HTML template")
381
+
382
+ def inject_router(self) -> str:
383
+ """
384
+ Inject single event router + binding data
385
+
386
+ This method should be called once at page startup.
387
+ Injects a single router that handles all events and registered bindings.
388
+
389
+ Returns:
390
+ <script> tag (router + binding data)
391
+
392
+ Example:
393
+ # Manual injection (advanced users)
394
+ vl.app.HTML_TEMPLATE = vl.app.HTML_TEMPLATE.replace(
395
+ '</body>',
396
+ f'{app.broadcaster.register_js_helpers()}'
397
+ f'{app.broadcaster.inject_router()}'
398
+ f'</body>'
399
+ )
400
+
401
+ # Auto injection (recommended)
402
+ app.broadcaster.inject_all()
403
+ """
404
+ if self._router_injected:
405
+ print("[BROADCAST] Warning: Router already injected!")
406
+
407
+ self._router_injected = True
408
+ bindings_json = json.dumps(self._bindings, ensure_ascii=False)
409
+
410
+ return f"""
411
+ <script>
412
+ // ========== Violit Event Router (Single Instance) ==========
413
+
414
+ window.violit = window.violit || {{}};
415
+ window.violit.eventCache = window.violit.eventCache || new Map(); // Prevent duplicates
416
+ window.violit.bindings = {bindings_json};
417
+
418
+ // ========== UI Primitives Implementation ==========
419
+
420
+ window.violit.primitives = {{
421
+ // ===== List Operations =====
422
+ 'list.append': (params) => {{
423
+ const container = document.getElementById(`${{params.list_key}}_reactive_list_container`);
424
+ if (!container) {{
425
+ console.warn(`[Violit] List container not found: ${{params.list_key}}`);
426
+ return;
427
+ }}
428
+
429
+ const data = params.item_data;
430
+ let element;
431
+
432
+ // Use custom HTML if provided (flexibility)
433
+ if (data.html) {{
434
+ const temp = document.createElement('div');
435
+ temp.innerHTML = data.html;
436
+ element = temp.firstChild;
437
+ }} else {{
438
+ // Create default card (backward compatibility)
439
+ element = window.violit.createLiveCard(data);
440
+ }}
441
+
442
+ if (params.position === 'prepend') {{
443
+ container.insertBefore(element, container.firstChild);
444
+ }} else {{
445
+ container.appendChild(element);
446
+ }}
447
+
448
+ // Animation
449
+ element.style.opacity = '0';
450
+ element.style.transform = 'translateY(-20px)';
451
+ setTimeout(() => {{
452
+ element.style.transition = 'all 0.5s ease-out';
453
+ element.style.opacity = '1';
454
+ element.style.transform = 'translateY(0)';
455
+ }}, 10);
456
+
457
+ console.log(`[Violit] List append: ${{params.list_key}}`, data.html ? '(custom HTML)' : '(default card)');
458
+ }},
459
+
460
+ 'list.remove': (params) => {{
461
+ const itemId = params.item_id;
462
+ const cards = document.querySelectorAll(`[data-post-id="${{itemId}}"]`);
463
+
464
+ cards.forEach(card => {{
465
+ card.style.transition = 'all 500ms ease-out';
466
+ card.style.opacity = '0';
467
+ card.style.transform = 'translateX(50px)';
468
+ setTimeout(() => card.remove(), 500);
469
+ }});
470
+
471
+ console.log(`[Violit] List remove: ${{itemId}}`);
472
+ }},
473
+
474
+ 'list.update': (params) => {{
475
+ const itemId = params.item_id;
476
+ const card = document.querySelector(`[data-post-id="${{itemId}}"]`);
477
+ if (card) {{
478
+ const newCard = window.violit.createLiveCard(params.item_data);
479
+ card.replaceWith(newCard);
480
+ console.log(`[Violit] List update: ${{itemId}}`);
481
+ }}
482
+ }},
483
+
484
+ 'list.replace_all': (params) => {{
485
+ const container = document.getElementById(`${{params.list_key}}_reactive_list_container`);
486
+ if (container) {{
487
+ container.innerHTML = '';
488
+ params.items.forEach(item => {{
489
+ const card = window.violit.createLiveCard(item);
490
+ container.appendChild(card);
491
+ }});
492
+ console.log(`[Violit] List replace all: ${{params.list_key}}`);
493
+ }}
494
+ }},
495
+
496
+ // ===== State Operations =====
497
+ 'state.set': (params) => {{
498
+ const elements = document.querySelectorAll(`[data-state-key="${{params.state_key}}"]`);
499
+ elements.forEach(el => {{
500
+ el.textContent = params.value;
501
+ }});
502
+ console.log(`[Violit] State set: ${{params.state_key}} = ${{params.value}}`);
503
+ }},
504
+
505
+ 'state.increment': (params) => {{
506
+ const elements = document.querySelectorAll(`[data-state-key="${{params.state_key}}"]`);
507
+ elements.forEach(el => {{
508
+ const current = parseInt(el.textContent) || 0;
509
+ el.textContent = current + (params.amount || 1);
510
+ }});
511
+ console.log(`[Violit] State increment: ${{params.state_key}} +${{params.amount || 1}}`);
512
+ }},
513
+
514
+ 'state.decrement': (params) => {{
515
+ const elements = document.querySelectorAll(`[data-state-key="${{params.state_key}}"]`);
516
+ elements.forEach(el => {{
517
+ const current = parseInt(el.textContent) || 0;
518
+ el.textContent = current - (params.amount || 1);
519
+ }});
520
+ console.log(`[Violit] State decrement: ${{params.state_key}} -${{params.amount || 1}}`);
521
+ }},
522
+
523
+ // ===== DOM Operations =====
524
+ 'dom.insert': (params) => {{
525
+ const container = document.getElementById(params.container_id);
526
+ if (container) {{
527
+ const temp = document.createElement('div');
528
+ temp.innerHTML = params.html;
529
+ const element = temp.firstChild;
530
+
531
+ if (params.position === 'prepend') {{
532
+ container.insertBefore(element, container.firstChild);
533
+ }} else {{
534
+ container.appendChild(element);
535
+ }}
536
+ console.log(`[Violit] DOM insert: ${{params.container_id}}`);
537
+ }}
538
+ }},
539
+
540
+ 'dom.remove': (params) => {{
541
+ const elements = document.querySelectorAll(params.selector);
542
+ elements.forEach(el => {{
543
+ if (params.animate) {{
544
+ el.style.transition = 'opacity 300ms';
545
+ el.style.opacity = '0';
546
+ setTimeout(() => el.remove(), 300);
547
+ }} else {{
548
+ el.remove();
549
+ }}
550
+ }});
551
+ console.log(`[Violit] DOM remove: ${{params.selector}}`);
552
+ }},
553
+
554
+ 'dom.update': (params) => {{
555
+ const elements = document.querySelectorAll(params.selector);
556
+ elements.forEach(el => {{
557
+ el.innerHTML = params.html;
558
+ }});
559
+ console.log(`[Violit] DOM update: ${{params.selector}}`);
560
+ }},
561
+
562
+ // ===== Feedback Operations =====
563
+ 'feedback.toast': (params) => {{
564
+ const alert = document.createElement('sl-alert');
565
+ alert.variant = params.variant || 'neutral';
566
+ alert.closable = true;
567
+ alert.duration = params.duration || 3000;
568
+
569
+ const iconName = {{
570
+ 'success': 'check-circle',
571
+ 'warning': 'exclamation-triangle',
572
+ 'danger': 'exclamation-octagon',
573
+ 'neutral': 'info-circle'
574
+ }}[params.variant] || 'info-circle';
575
+
576
+ alert.innerHTML = `<sl-icon slot="icon" name="${{iconName}}"></sl-icon>${{params.message}}`;
577
+ document.body.appendChild(alert);
578
+ alert.toast();
579
+
580
+ console.log(`[Violit] Toast: ${{params.message}}`);
581
+ }},
582
+
583
+ 'feedback.badge': (params) => {{
584
+ const badge = document.querySelector(`#${{params.badge_id}}`);
585
+ if (badge) {{
586
+ badge.textContent = params.value;
587
+ console.log(`[Violit] Badge update: ${{params.badge_id}} = ${{params.value}}`);
588
+ }}
589
+ }}
590
+ }};
591
+
592
+ // ========== Event Router ==========
593
+
594
+ window.violit.routeEvent = (eventName, detail) => {{
595
+ const binding = window.violit.bindings[eventName];
596
+ if (!binding) {{
597
+ console.warn(`[Violit] No binding found for event: ${{eventName}}`);
598
+ return;
599
+ }}
600
+
601
+ // Check duplicates
602
+ if (binding.meta?.dedupe) {{
603
+ const eventId = detail._eventId || JSON.stringify(detail);
604
+ if (window.violit.eventCache.has(eventId)) {{
605
+ console.log(`[Violit] Duplicate event ignored: ${{eventName}}`);
606
+ return;
607
+ }}
608
+ window.violit.eventCache.set(eventId, Date.now());
609
+
610
+ // Clean cache (keep only recent 100)
611
+ if (window.violit.eventCache.size > 100) {{
612
+ const firstKey = window.violit.eventCache.keys().next().value;
613
+ window.violit.eventCache.delete(firstKey);
614
+ }}
615
+ }}
616
+
617
+ // Execute primitives
618
+ binding.primitives.forEach(primitive => {{
619
+ try {{
620
+ // Evaluate parameters (execute JS expressions)
621
+ const evaluatedParams = {{}};
622
+ const e = {{ detail }}; // Event object
623
+
624
+ for (const [key, value] of Object.entries(primitive.params)) {{
625
+ if (typeof value === 'string' && value.includes('e.detail')) {{
626
+ try {{
627
+ evaluatedParams[key] = eval(value);
628
+ }} catch (err) {{
629
+ console.warn(`[Violit] Failed to evaluate param ${{key}}: ${{value}}`, err);
630
+ evaluatedParams[key] = value;
631
+ }}
632
+ }} else {{
633
+ evaluatedParams[key] = value;
634
+ }}
635
+ }}
636
+
637
+ // Execute primitive
638
+ const primitiveFunc = window.violit.primitives[primitive.type];
639
+ if (primitiveFunc) {{
640
+ primitiveFunc(evaluatedParams);
641
+ }} else {{
642
+ console.error(`[Violit] Unknown primitive: ${{primitive.type}}`);
643
+ }}
644
+ }} catch (error) {{
645
+ console.error(`[Violit] Error executing primitive ${{primitive.type}}:`, error);
646
+ }}
647
+ }});
648
+
649
+ console.log(`✅ Event routed: ${{eventName}}`);
650
+ }};
651
+
652
+ // ========== Global Event Listener (Single Instance) ==========
653
+
654
+ // Prevent duplicate listener registration on page reload
655
+ if (!window.violit.listenersRegistered) {{
656
+ // Connect all custom events to router
657
+ Object.keys(window.violit.bindings).forEach(eventName => {{
658
+ window.addEventListener(eventName, (e) => {{
659
+ window.violit.routeEvent(eventName, e.detail);
660
+ }});
661
+ }});
662
+
663
+ window.violit.listenersRegistered = true;
664
+ console.log('✅ Violit Event Router loaded');
665
+ console.log('📋 Registered events:', Object.keys(window.violit.bindings));
666
+ }} else {{
667
+ console.log('⚠️ Violit Event Router already loaded (skipped duplicate registration)');
668
+ }}
669
+ </script>
670
+ """
671
+
672
+ # ========== Legacy/Compatibility APIs ==========
673
+
674
+ def reload_all(self, exclude_current: bool = False):
675
+ """Reload all client pages (Legacy API)"""
676
+ self.eval_all("window.location.reload();", exclude_current=exclude_current)
677
+
678
+
679
+ # Convenience function
680
+ def create_broadcaster(app) -> Broadcaster:
681
+ """
682
+ Create Broadcaster instance
683
+
684
+ Args:
685
+ app: Violit App instance
686
+
687
+ Returns:
688
+ Broadcaster instance
689
+ """
690
+ return Broadcaster(app)