Skip to content

Commit 16e3565

Browse files
committed
Implement broadcast
1 parent d30f515 commit 16e3565

3 files changed

Lines changed: 165 additions & 110 deletions

File tree

examples/broadcast.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import asyncio
2+
from contextlib import asynccontextmanager
3+
from violetear import App, Document, HTML
4+
from violetear.style import Style
5+
6+
# Initialize app
7+
app = App(title="Live Ping", version="v1")
8+
9+
10+
# --- 1. Client Side (Updates the UI) ---
11+
@app.client
12+
async def update_counter(count: int):
13+
# This runs in the User's Browser
14+
from violetear.dom import Document
15+
16+
el = Document.find("counter")
17+
el.text = f"Server Pings: {count}"
18+
el.style(color = "red" if count % 2 == 0 else "blue")
19+
20+
21+
# --- 2. Server Side (Background Task) ---
22+
async def background_pinger():
23+
"""Simulates a server event happening every second."""
24+
count = 0
25+
while True:
26+
await asyncio.sleep(1)
27+
count += 1
28+
29+
# RPC BROADCAST: Sends 'count' to ALL connected browsers
30+
await update_counter.broadcast(count)
31+
32+
33+
# --- Lifecycle Management ---
34+
@asynccontextmanager
35+
async def lifespan(api):
36+
# Startup
37+
task = asyncio.create_task(background_pinger())
38+
yield
39+
# Shutdown (optional cleanup)
40+
task.cancel()
41+
42+
43+
# Hook into the internal FastAPI router to register lifespan
44+
app.api.router.lifespan_context = lifespan
45+
46+
47+
# --- 3. The UI (HTML) ---
48+
@app.route("/")
49+
def home():
50+
# 1. Create the Document
51+
doc = Document(title="Live Counter")
52+
53+
# 2. Build the Body using HTML helpers
54+
# We use inline styles here for simplicity, but StyleSheet() is better for real apps
55+
56+
doc.body.add(
57+
HTML.div(
58+
style=Style().height("100vh").flexbox(align="center", justify="center")
59+
).add(
60+
HTML.h1(
61+
id="counter",
62+
text="Waiting for server...",
63+
style=Style().font(size="3rem", family="sans-serif", weight="bold"),
64+
)
65+
)
66+
)
67+
68+
return doc
69+
70+
71+
if __name__ == "__main__":
72+
app.run()

violetear/app.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ async def websocket_endpoint(websocket: WebSocket):
118118
# Keep the connection alive.
119119
# We can also listen for client-to-server messages here if needed later.
120120
await websocket.receive()
121-
except WebSocketDisconnect:
121+
except (WebSocketDisconnect, RuntimeError):
122122
self.socket_manager.disconnect(websocket)
123123

124124
def client(self, func: Callable):
@@ -415,7 +415,7 @@ def _inject_client_side(self, doc: Document):
415415
}}
416416
}}
417417
418-
await main();
418+
main();
419419
"""
420420
)
421421

@@ -546,7 +546,7 @@ async def wrapper(request: Request):
546546
self._register_document_styles(response)
547547

548548
# Check if this document contains Python bindings
549-
if response.body.has_bindings():
549+
if response.body.has_bindings() or self.client_functions:
550550
self._inject_client_side(response)
551551

552552
# Inject PWA tags if enabled for this route
@@ -608,16 +608,10 @@ async def broadcast(self, *args, **kwargs):
608608
609609
Tells the server to instructing ALL connected clients to run this function.
610610
"""
611-
# FUTURE: This will hook into the WebSocket manager
612-
if hasattr(self.app, "socket_manager"):
613-
# print(f"Broadcasting {self.__name__} to all clients...")
614-
await self.app.socket_manager.broadcast(
615-
func_name=self.__name__, args=args, kwargs=kwargs
616-
)
617-
else:
618-
print(
619-
f"[Violetear] Warning: Broadcast called on '{self.__name__}' but no SocketManager is active."
620-
)
611+
await self.app.socket_manager.broadcast(
612+
func_name=self.__name__, args=args, kwargs=kwargs
613+
)
614+
print(f"[Violetear] Broadcasting {self.__name__} with args={args} and kwargs={kwargs}")
621615

622616

623617
# Add this class to violetear/app.py

violetear/client.py

Lines changed: 86 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,105 @@
1-
"""
2-
Violetear Client Runtime.
3-
This code runs inside the browser (Pyodide) to bring the static HTML to life.
4-
"""
5-
6-
import sys
7-
import json
81
import asyncio
2+
import json
3+
from js import document, window, WebSocket, console
4+
from pyodide.ffi import create_proxy
95

10-
# We define IS_BROWSER to avoid import errors if this is imported on the server
11-
IS_BROWSER = "pyodide" in sys.modules or "emscripten" in sys.platform
126

7+
def get_socket_url():
8+
"""Calculates the correct WebSocket URL based on the current page."""
9+
protocol = "wss" if window.location.protocol == "https:" else "ws"
10+
host = window.location.host
11+
return f"{protocol}://{host}/_violetear/ws"
1312

14-
def hydrate(namespace: dict):
15-
"""
16-
Scans the DOM for Violetear interactive elements and binds them to
17-
Python functions found in the provided namespace.
1813

19-
Args:
20-
namespace: A dictionary mapping function names to callables.
21-
Typically, you pass `globals()` here from your client script.
14+
def setup_socket_listener(scope):
2215
"""
23-
if not IS_BROWSER:
24-
raise TypeError("Hydration called outside of browser environment. Skipping.")
25-
26-
from js import document
27-
from pyodide.ffi import create_proxy
28-
29-
# We explicitly scan for common events.
30-
# In the future, we could inspect the DOM more aggressively or use a MutationObserver.
31-
supported_events = [
32-
"click",
33-
"change",
34-
"input",
35-
"submit",
36-
"keydown",
37-
"keyup",
38-
"mouseenter",
39-
"mouseleave",
40-
]
41-
42-
bound_count = 0
43-
44-
for event_name in supported_events:
45-
# The markup generates attributes like: data-py-on-click="my_func"
46-
attr = f"data-on-{event_name}"
47-
selector = f"[{attr}]"
48-
49-
elements = document.querySelectorAll(selector)
50-
51-
for element in elements:
52-
handler_name = element.getAttribute(attr)
53-
54-
if handler_name in namespace:
55-
handler_func = namespace[handler_name]
56-
57-
# 1. Create a Pyodide Proxy
58-
# We wrap the python function so JS can call it safely.
59-
# 'create_proxy' ensures the function isn't garbage collected immediately.
60-
proxy = create_proxy(handler_func)
61-
62-
# 2. Bind the Listener
63-
# We attach the Python proxy directly to the JS event listener
64-
element.addEventListener(event_name, proxy)
65-
66-
# 3. Cleanup (Optional)
67-
# We remove the data attribute so we don't double-bind if hydrate is called again
68-
element.removeAttribute(attr)
69-
70-
bound_count += 1
71-
else:
72-
print(
73-
f"[Violetear] Warning: Function '{handler_name}' not found for event '{event_name}'"
74-
)
16+
Establishes a WebSocket connection to the server for RPC.
17+
"""
18+
url = get_socket_url()
19+
socket = WebSocket.new(url)
7520

76-
print(f"[Violetear] Hydrated {bound_count} interactive elements.")
77-
setup_socket_listener(namespace)
21+
print("Setting up sockets")
7822

23+
def on_open(event):
24+
console.log(f"[Violetear] ✅ Connected to Live RPC at {url}")
7925

80-
def setup_socket_listener(namespace):
26+
def on_message(event):
27+
"""
28+
Handles incoming RPC commands from the server.
29+
"""
30+
try:
31+
# event.data is a string coming from the server
32+
data = json.loads(event.data)
33+
34+
if data.get("type") == "rpc":
35+
func_name = data["func"]
36+
args = data.get("args", [])
37+
kwargs = data.get("kwargs", {})
38+
39+
# 1. Find the function in the client's global scope
40+
if func_name in scope:
41+
func = scope[func_name]
42+
43+
# 2. Schedule the execution
44+
# Use asyncio to ensure it runs properly in the Pyodide loop
45+
asyncio.create_task(func(*args, **kwargs))
46+
else:
47+
console.warn(
48+
f"[Violetear] ⚠️ RPC Warning: Function '{func_name}' not found in client scope."
49+
)
50+
51+
except Exception as e:
52+
console.error(f"[Violetear] ❌ RPC Error: {str(e)}")
53+
54+
def on_close(event):
55+
console.log("[Violetear] 🔌 Connection lost. Reconnecting in 3s...")
56+
# Use create_proxy for the timeout callback too
57+
retry = create_proxy(lambda: setup_socket_listener(scope))
58+
window.setTimeout(retry, 3000)
59+
60+
# Use create_proxy to ensure these python functions aren't garbage collected
61+
# while the JS side still needs them.
62+
socket.onopen = create_proxy(on_open)
63+
socket.onmessage = create_proxy(on_message)
64+
socket.onclose = create_proxy(on_close)
65+
66+
# Keep reference to socket on window to prevent it from being GC'd
67+
window.violetear_socket = socket
68+
69+
70+
def hydrate(scope):
8171
"""
82-
Connects to the server and listens for RPC commands.
72+
Scans the DOM for [data-on-event] attributes and binds them to Python functions.
8373
"""
84-
from js import WebSocket, window
85-
86-
# Calculate the WebSocket URL (ws:// or wss://)
87-
protocol = "wss" if window.location.protocol == "https:" else "ws"
88-
ws_url = f"{protocol}://{window.location.host}/_violetear/ws"
8974

90-
socket = WebSocket.new(ws_url)
75+
def create_handler(func_name):
76+
async def handler(event):
77+
if func_name in scope:
78+
await scope[func_name](event)
79+
else:
80+
console.error(f"Handler '{func_name}' not found")
9181

92-
def on_message(event):
93-
data = json.loads(event.data)
82+
return handler
9483

95-
if data.get("type") == "rpc":
96-
func_name = data["func"]
97-
args = data["args"]
98-
kwargs = data["kwargs"]
84+
elements = document.querySelectorAll("*")
9985

100-
# 1. Look up the function in the global scope
101-
if func_name in namespace:
102-
func = namespace[func_name]
86+
for i in range(elements.length):
87+
el = elements.item(i)
88+
for attr in el.attributes:
89+
name = attr.name
90+
if name.startswith("data-on-"):
91+
event_name = name.replace("data-on-", "")
92+
func_name = attr.value
10393

104-
# 2. Schedule the async function to run on the event loop
105-
# We use asyncio.create_task because we are inside a sync callback
106-
asyncio.create_task(func(*args, **kwargs))
107-
else:
108-
print(f"[Violetear] Received RPC for unknown function: {func_name}")
94+
# Bind the event using create_proxy
95+
handler = create_handler(func_name)
96+
proxy = create_proxy(handler)
10997

110-
# Attach the callback (converting Python function to JS proxy not strictly needed for simple events in recent Pyodide, but good practice)
111-
socket.onmessage = on_message
98+
# Store proxy on element to prevent GC (optional but good practice)
99+
if not hasattr(el, "_py_listeners"):
100+
el._py_listeners = []
101+
el._py_listeners.append(proxy)
112102

113-
# Keep a reference so it doesn't get garbage collected
114-
window.violetear_socket = socket
103+
el.addEventListener(event_name, proxy)
115104

116-
print("[Violetear] Attached socket connection")
105+
setup_socket_listener(scope)

0 commit comments

Comments
 (0)