|
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 |
8 | 1 | import asyncio |
| 2 | +import json |
| 3 | +from js import document, window, WebSocket, console |
| 4 | +from pyodide.ffi import create_proxy |
9 | 5 |
|
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 |
12 | 6 |
|
| 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" |
13 | 12 |
|
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. |
18 | 13 |
|
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): |
22 | 15 | """ |
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) |
75 | 20 |
|
76 | | - print(f"[Violetear] Hydrated {bound_count} interactive elements.") |
77 | | - setup_socket_listener(namespace) |
| 21 | + print("Setting up sockets") |
78 | 22 |
|
| 23 | + def on_open(event): |
| 24 | + console.log(f"[Violetear] ✅ Connected to Live RPC at {url}") |
79 | 25 |
|
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): |
81 | 71 | """ |
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. |
83 | 73 | """ |
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" |
89 | 74 |
|
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") |
91 | 81 |
|
92 | | - def on_message(event): |
93 | | - data = json.loads(event.data) |
| 82 | + return handler |
94 | 83 |
|
95 | | - if data.get("type") == "rpc": |
96 | | - func_name = data["func"] |
97 | | - args = data["args"] |
98 | | - kwargs = data["kwargs"] |
| 84 | + elements = document.querySelectorAll("*") |
99 | 85 |
|
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 |
103 | 93 |
|
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) |
109 | 97 |
|
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) |
112 | 102 |
|
113 | | - # Keep a reference so it doesn't get garbage collected |
114 | | - window.violetear_socket = socket |
| 103 | + el.addEventListener(event_name, proxy) |
115 | 104 |
|
116 | | - print("[Violetear] Attached socket connection") |
| 105 | + setup_socket_listener(scope) |
0 commit comments