pywire 0.1.0__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.
Files changed (104) hide show
  1. pywire/__init__.py +2 -0
  2. pywire/cli/__init__.py +1 -0
  3. pywire/cli/generators.py +48 -0
  4. pywire/cli/main.py +309 -0
  5. pywire/cli/tui.py +563 -0
  6. pywire/cli/validate.py +26 -0
  7. pywire/client/.prettierignore +8 -0
  8. pywire/client/.prettierrc +7 -0
  9. pywire/client/build.mjs +73 -0
  10. pywire/client/eslint.config.js +46 -0
  11. pywire/client/package.json +39 -0
  12. pywire/client/pnpm-lock.yaml +2971 -0
  13. pywire/client/src/core/app.ts +263 -0
  14. pywire/client/src/core/dom-updater.test.ts +78 -0
  15. pywire/client/src/core/dom-updater.ts +321 -0
  16. pywire/client/src/core/index.ts +5 -0
  17. pywire/client/src/core/transport-manager.test.ts +179 -0
  18. pywire/client/src/core/transport-manager.ts +159 -0
  19. pywire/client/src/core/transports/base.ts +122 -0
  20. pywire/client/src/core/transports/http.ts +142 -0
  21. pywire/client/src/core/transports/index.ts +13 -0
  22. pywire/client/src/core/transports/websocket.ts +97 -0
  23. pywire/client/src/core/transports/webtransport.ts +149 -0
  24. pywire/client/src/dev/dev-app.ts +93 -0
  25. pywire/client/src/dev/error-trace.test.ts +97 -0
  26. pywire/client/src/dev/error-trace.ts +76 -0
  27. pywire/client/src/dev/index.ts +4 -0
  28. pywire/client/src/dev/status-overlay.ts +63 -0
  29. pywire/client/src/events/handler.test.ts +318 -0
  30. pywire/client/src/events/handler.ts +454 -0
  31. pywire/client/src/pywire.core.ts +22 -0
  32. pywire/client/src/pywire.dev.ts +27 -0
  33. pywire/client/tsconfig.json +17 -0
  34. pywire/client/vitest.config.ts +15 -0
  35. pywire/compiler/__init__.py +6 -0
  36. pywire/compiler/ast_nodes.py +304 -0
  37. pywire/compiler/attributes/__init__.py +6 -0
  38. pywire/compiler/attributes/base.py +24 -0
  39. pywire/compiler/attributes/conditional.py +37 -0
  40. pywire/compiler/attributes/events.py +55 -0
  41. pywire/compiler/attributes/form.py +37 -0
  42. pywire/compiler/attributes/loop.py +75 -0
  43. pywire/compiler/attributes/reactive.py +34 -0
  44. pywire/compiler/build.py +28 -0
  45. pywire/compiler/build_artifacts.py +342 -0
  46. pywire/compiler/codegen/__init__.py +5 -0
  47. pywire/compiler/codegen/attributes/__init__.py +6 -0
  48. pywire/compiler/codegen/attributes/base.py +19 -0
  49. pywire/compiler/codegen/attributes/events.py +35 -0
  50. pywire/compiler/codegen/directives/__init__.py +6 -0
  51. pywire/compiler/codegen/directives/base.py +16 -0
  52. pywire/compiler/codegen/directives/path.py +53 -0
  53. pywire/compiler/codegen/generator.py +2341 -0
  54. pywire/compiler/codegen/template.py +2178 -0
  55. pywire/compiler/directives/__init__.py +7 -0
  56. pywire/compiler/directives/base.py +20 -0
  57. pywire/compiler/directives/component.py +33 -0
  58. pywire/compiler/directives/context.py +93 -0
  59. pywire/compiler/directives/layout.py +49 -0
  60. pywire/compiler/directives/no_spa.py +24 -0
  61. pywire/compiler/directives/path.py +71 -0
  62. pywire/compiler/directives/props.py +88 -0
  63. pywire/compiler/exceptions.py +19 -0
  64. pywire/compiler/interpolation/__init__.py +6 -0
  65. pywire/compiler/interpolation/base.py +28 -0
  66. pywire/compiler/interpolation/jinja.py +272 -0
  67. pywire/compiler/parser.py +750 -0
  68. pywire/compiler/paths.py +29 -0
  69. pywire/compiler/preprocessor.py +43 -0
  70. pywire/core/wire.py +119 -0
  71. pywire/py.typed +0 -0
  72. pywire/runtime/__init__.py +7 -0
  73. pywire/runtime/aioquic_server.py +194 -0
  74. pywire/runtime/app.py +889 -0
  75. pywire/runtime/compile_error_page.py +195 -0
  76. pywire/runtime/debug.py +203 -0
  77. pywire/runtime/dev_server.py +434 -0
  78. pywire/runtime/dev_server.py.broken +268 -0
  79. pywire/runtime/error_page.py +64 -0
  80. pywire/runtime/error_renderer.py +23 -0
  81. pywire/runtime/escape.py +23 -0
  82. pywire/runtime/files.py +40 -0
  83. pywire/runtime/helpers.py +97 -0
  84. pywire/runtime/http_transport.py +253 -0
  85. pywire/runtime/loader.py +272 -0
  86. pywire/runtime/logging.py +72 -0
  87. pywire/runtime/page.py +384 -0
  88. pywire/runtime/pydantic_integration.py +52 -0
  89. pywire/runtime/router.py +229 -0
  90. pywire/runtime/server.py +25 -0
  91. pywire/runtime/style_collector.py +31 -0
  92. pywire/runtime/upload_manager.py +76 -0
  93. pywire/runtime/validation.py +449 -0
  94. pywire/runtime/websocket.py +665 -0
  95. pywire/runtime/webtransport_handler.py +195 -0
  96. pywire/templates/error/404.html +11 -0
  97. pywire/templates/error/500.html +38 -0
  98. pywire/templates/error/base.html +207 -0
  99. pywire/templates/error/compile_error.html +31 -0
  100. pywire-0.1.0.dist-info/METADATA +50 -0
  101. pywire-0.1.0.dist-info/RECORD +104 -0
  102. pywire-0.1.0.dist-info/WHEEL +4 -0
  103. pywire-0.1.0.dist-info/entry_points.txt +2 -0
  104. pywire-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,665 @@
1
+ """WebSocket handler for PyWire."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import sys
6
+ import traceback
7
+ from typing import Any, Dict, Set
8
+
9
+ import msgpack
10
+ from starlette.responses import Response
11
+ from starlette.websockets import WebSocket, WebSocketDisconnect
12
+
13
+ from pywire.runtime.logging import log_callback_ctx
14
+ from pywire.runtime.page import BasePage
15
+
16
+
17
+ class WebSocketHandler:
18
+ """Handles WebSocket connections for events and hot reload."""
19
+
20
+ def __init__(self, app: Any) -> None:
21
+ self.app = app
22
+ self.active_connections: Set[WebSocket] = set()
23
+ # Map websocket to page instance
24
+ self.connection_pages: Dict[WebSocket, BasePage] = {}
25
+
26
+ async def handle(self, websocket: WebSocket) -> None:
27
+ """Handle new WebSocket connection."""
28
+ # Optional: Auth check hook
29
+ if hasattr(self.app, "on_ws_connect"):
30
+ if not await self.app.on_ws_connect(websocket):
31
+ await websocket.close()
32
+ return
33
+
34
+ await websocket.accept()
35
+ self.active_connections.add(websocket)
36
+
37
+ try:
38
+ # Create isolated page instance for this connection
39
+ # We need to reconstruct the page based on current URL
40
+ # Note: This simplifies things by assuming initial state.
41
+ # Real session support would hydrate state here.
42
+
43
+ # Since we don't have the request context easily here yet without
44
+ # more complex routing, we wait for the first event to associate/create
45
+ # the page if needed, or we rely on the client to send initial context.
46
+ # For this MVP, we'll instantiate the page when an event arrives.
47
+
48
+ while True:
49
+ data_bytes = await websocket.receive_bytes()
50
+ data = msgpack.unpackb(data_bytes, raw=False)
51
+ await self._process_message(websocket, data)
52
+
53
+ except WebSocketDisconnect:
54
+ self.active_connections.remove(websocket)
55
+ if websocket in self.connection_pages:
56
+ del self.connection_pages[websocket]
57
+ except asyncio.CancelledError:
58
+ # Server shutdown, clean disconnect
59
+ self.active_connections.discard(websocket)
60
+ if websocket in self.connection_pages:
61
+ del self.connection_pages[websocket]
62
+ # Don't re-raise, let it exit gracefully
63
+ return
64
+ except Exception as e:
65
+ print(f"WebSocket error: {e}")
66
+ import traceback
67
+
68
+ traceback.print_exc()
69
+
70
+ async def _process_message(
71
+ self, websocket: WebSocket, data: Dict[str, Any]
72
+ ) -> None:
73
+ """Process incoming message from client."""
74
+ msg_type = data.get("type")
75
+
76
+ if msg_type == "event":
77
+ await self._handle_event(websocket, data)
78
+ elif msg_type == "relocate":
79
+ await self._handle_relocate(websocket, data)
80
+ else:
81
+ print(f"Unknown message type: {msg_type}")
82
+ await self._send_console_message(
83
+ websocket, f"Unknown message type: {msg_type}", level="error"
84
+ )
85
+
86
+ async def _send_console_message(
87
+ self, websocket: WebSocket, output: str, level: str = "info"
88
+ ) -> None:
89
+ """Send a console log message to the client."""
90
+ # Split by newlines to send as list
91
+ lines = output.splitlines()
92
+ if not lines:
93
+ return
94
+
95
+ await websocket.send_bytes(
96
+ msgpack.packb({"type": "console", "lines": lines, "level": level})
97
+ )
98
+
99
+ async def _send_error_trace(self, websocket: WebSocket, error: Exception) -> None:
100
+ """Send a structured error trace to the client."""
101
+ # Gate on debug mode + dev mode
102
+ # If not in dev mode, send generic error message only
103
+ if not (self.app.debug and getattr(self.app, "_is_dev_mode", False)):
104
+ await websocket.send_bytes(
105
+ msgpack.packb(
106
+ {
107
+ "type": "error",
108
+ "error": f"{type(error).__name__}: An error occurred",
109
+ }
110
+ )
111
+ )
112
+ return
113
+
114
+ exc_type, exc_value, exc_traceback = sys.exc_info()
115
+ trace = []
116
+ if exc_traceback:
117
+ # Skip the first frame if it's just the wrapper?
118
+ # traceback.extract_tb returns all frames.
119
+ summary = traceback.extract_tb(exc_traceback)
120
+ current_tb = exc_traceback
121
+ for frame in summary:
122
+ frame_data = {
123
+ "filename": frame.filename,
124
+ "lineno": frame.lineno,
125
+ "name": frame.name,
126
+ "line": frame.line,
127
+ }
128
+
129
+ # Python 3.11+ provides column information
130
+ if hasattr(frame, "colno") and frame.colno is not None:
131
+ frame_data["colno"] = frame.colno
132
+ if hasattr(frame, "end_colno") and frame.end_colno is not None:
133
+ frame_data["end_colno"] = frame.end_colno
134
+
135
+ # Fallback: Manual extraction from raw traceback frame if colno missing
136
+ if "colno" not in frame_data and current_tb:
137
+ try:
138
+ # Verify we are on the same frame (basic check)
139
+ if current_tb.tb_frame.f_code.co_filename == frame.filename:
140
+ code = current_tb.tb_frame.f_code
141
+ if hasattr(code, "co_positions"):
142
+ # f_lasti is byte offset, instructions are 2 bytes
143
+ idx = current_tb.tb_frame.f_lasti // 2
144
+ positions = list(code.co_positions())
145
+ if idx < len(positions):
146
+ line, end_line, col, end_col = positions[idx]
147
+ if col is not None:
148
+ frame_data["colno"] = col
149
+ if end_col is not None:
150
+ frame_data["end_colno"] = end_col
151
+ except Exception:
152
+ # Silently fail manual extraction
153
+ pass
154
+
155
+ # Advance to next raw frame
156
+ if current_tb:
157
+ current_tb = current_tb.tb_next # type: ignore # tb_next is Optional[TracebackType]
158
+
159
+ trace.append(frame_data)
160
+
161
+ await websocket.send_bytes(
162
+ msgpack.packb(
163
+ {
164
+ "type": "error_trace",
165
+ "error": f"{type(error).__name__}: {str(error)}",
166
+ "trace": trace,
167
+ }
168
+ )
169
+ )
170
+
171
+ async def _send_update_payload(self, websocket: WebSocket, update: Any) -> None:
172
+ if isinstance(update, Response):
173
+ html = bytes(update.body).decode("utf-8")
174
+ await websocket.send_bytes(msgpack.packb({"type": "update", "html": html}))
175
+ return
176
+
177
+ if isinstance(update, dict):
178
+ if update.get("type") == "regions":
179
+ await websocket.send_bytes(
180
+ msgpack.packb(
181
+ {"type": "update", "regions": update.get("regions", [])}
182
+ )
183
+ )
184
+ return
185
+ if update.get("type") == "full":
186
+ html = update.get("html", "")
187
+ await websocket.send_bytes(
188
+ msgpack.packb({"type": "update", "html": html})
189
+ )
190
+ return
191
+
192
+ # Fallback: force full reload
193
+ await websocket.send_bytes(msgpack.packb({"type": "reload"}))
194
+
195
+ async def _handle_event(self, websocket: WebSocket, data: Dict[str, Any]) -> None:
196
+ """Handle UI event (click, etc)."""
197
+ handler_name = data.get("handler")
198
+ path = data.get("path", "/")
199
+ event_data = data.get("data", {})
200
+
201
+ # Define callback for log streaming
202
+ async def send_log(msg: str, level: str = "info") -> None:
203
+ if msg and msg.strip():
204
+ await self._send_console_message(websocket, output=msg, level=level)
205
+
206
+ # Set context for this operation
207
+ token = log_callback_ctx.set(send_log)
208
+
209
+ try:
210
+ # Get or create page instance
211
+ if websocket not in self.connection_pages:
212
+ # Find page stuff (logic copied from existing)
213
+ # ...
214
+ # Actually, duplicate logic from _handle_relocate is risky.
215
+ # Do we need to recreate page here?
216
+ # The original code did have logic to CREATE page if missing.
217
+ # Let's verify if I can just use self.connection_pages[websocket]
218
+ # If it's not there, maybe we should return or error?
219
+ # Original code checked `if websocket not in self.connection_pages`
220
+ # at start of try block.
221
+
222
+ # Re-implementing logic from reading Step 777 (which showed start of try)
223
+ # lines 116-179 in Step 777.
224
+ # I should just reference specific logic.
225
+ from urllib.parse import parse_qs, urlparse
226
+
227
+ # Create minimal request-like object if needed, or update Page
228
+ # to accept None/minimal context for WS mode
229
+ # For now, we'll pass a mock request or the websocket itself if Page supports it
230
+ from starlette.requests import Request
231
+
232
+ from pywire.runtime.router import URLHelper
233
+
234
+ parsed_url = urlparse(path)
235
+ pathname = parsed_url.path
236
+ query_string = parsed_url.query
237
+
238
+ match = self.app.router.match(pathname)
239
+ if not match:
240
+ print(f"No route found for path: {pathname}")
241
+ return
242
+
243
+ page_class, params, variant_name = match
244
+
245
+ # Construct a mock request from the websocket scope
246
+ # This is a simplification; ideally Page accepts WebSocket or Request
247
+ # Construct a mock request with the correct page path
248
+ # We copy scope to avoid mutating the actual WebSocket scope
249
+ scope = dict(websocket.scope)
250
+ scope["type"] = "http"
251
+ scope["path"] = pathname
252
+ scope["raw_path"] = pathname.encode("ascii")
253
+ scope["query_string"] = (
254
+ query_string.encode("ascii") if query_string else b""
255
+ )
256
+ # Ensure minimal requirements for valid Request
257
+ if "headers" not in scope:
258
+ scope["headers"] = [(b"host", b"localhost")]
259
+ if "method" not in scope:
260
+ scope["method"] = "GET"
261
+ if "scheme" not in scope:
262
+ scope["scheme"] = "http"
263
+ if "server" not in scope:
264
+ scope["server"] = ("localhost", 80)
265
+ if "client" not in scope:
266
+ scope["client"] = ("127.0.0.1", 0)
267
+
268
+ request = Request(scope)
269
+
270
+ if query_string:
271
+ parsed = parse_qs(query_string)
272
+ query = {k: v[0] if len(v) == 1 else v for k, v in parsed.items()}
273
+ else:
274
+ query = {}
275
+
276
+ path_info = {}
277
+ if hasattr(page_class, "__routes__"):
278
+ for name in page_class.__routes__.keys():
279
+ path_info[name] = name == variant_name
280
+
281
+ url_helper = None
282
+ if hasattr(page_class, "__routes__"):
283
+ url_helper = URLHelper(page_class.__routes__)
284
+
285
+ page = page_class(
286
+ request, params, query, path=path_info, url=url_helper
287
+ )
288
+ if hasattr(self.app, "get_user"):
289
+ page.user = self.app.get_user(websocket)
290
+
291
+ self.connection_pages[websocket] = page
292
+
293
+ if hasattr(page, "on_load"):
294
+ if inspect.iscoroutinefunction(page.on_load):
295
+ await page.on_load()
296
+ else:
297
+ page.on_load()
298
+ else:
299
+ page = self.connection_pages[websocket]
300
+
301
+ # Define update broadcaster
302
+ async def broadcast_update() -> None:
303
+ update = await page.render_update(init=False)
304
+ await self._send_update_payload(websocket, update)
305
+
306
+ page._on_update = broadcast_update
307
+
308
+ # Call handler
309
+ try:
310
+ update = await page.handle_event(handler_name, event_data)
311
+ except Exception as e:
312
+ raise e
313
+
314
+ await self._send_update_payload(websocket, update)
315
+
316
+ except Exception as e:
317
+ # Send structured trace to client (no print - trace is sufficient)
318
+ await self._send_error_trace(websocket, e)
319
+ finally:
320
+ log_callback_ctx.reset(token)
321
+
322
+ async def _handle_relocate(
323
+ self, websocket: WebSocket, data: Dict[str, Any]
324
+ ) -> None:
325
+ """Handle SPA navigation between sibling paths."""
326
+
327
+ # Define callback for log streaming
328
+ # Define callback for log streaming
329
+ async def send_log(msg: str, level: str = "info") -> None:
330
+ if msg and msg.strip():
331
+ await self._send_console_message(websocket, output=msg, level=level)
332
+
333
+ token = log_callback_ctx.set(send_log)
334
+
335
+ try:
336
+ path = data.get("path", "/")
337
+
338
+ # Get existing page instance
339
+ page = self.connection_pages.get(websocket)
340
+ if not page:
341
+ # No page instance yet - create one for this path
342
+ # This happens when user navigates via SPA link before any @click
343
+ from urllib.parse import parse_qs, urlparse
344
+
345
+ from starlette.requests import Request
346
+
347
+ from pywire.runtime.router import URLHelper
348
+
349
+ parsed_url = urlparse(path)
350
+ pathname = parsed_url.path
351
+ query_string = parsed_url.query
352
+
353
+ match = self.app.router.match(pathname)
354
+ if not match:
355
+ print(f"Relocate: No route found for path: {pathname}")
356
+ # Command client to perform a full reload (which will hit the server and 404)
357
+ await websocket.send_bytes(msgpack.packb({"type": "reload"}))
358
+ return
359
+
360
+ page_class, params, variant_name = match
361
+
362
+ # Create request with correct path
363
+ scope = dict(websocket.scope)
364
+ scope["type"] = "http"
365
+ scope["path"] = pathname
366
+ scope["raw_path"] = pathname.encode("ascii")
367
+ scope["query_string"] = (
368
+ query_string.encode("ascii") if query_string else b""
369
+ )
370
+ # Ensure minimal requirements for valid Request
371
+ if "headers" not in scope:
372
+ scope["headers"] = [(b"host", b"localhost")]
373
+ if "method" not in scope:
374
+ scope["method"] = "GET"
375
+ if "scheme" not in scope:
376
+ scope["scheme"] = "http"
377
+ if "server" not in scope:
378
+ scope["server"] = ("localhost", 80)
379
+ if "client" not in scope:
380
+ scope["client"] = ("127.0.0.1", 0)
381
+ request = Request(scope)
382
+
383
+ # Parse query
384
+ if query_string:
385
+ parsed = parse_qs(query_string)
386
+ query = {k: v[0] if len(v) == 1 else v for k, v in parsed.items()}
387
+ else:
388
+ query = {}
389
+
390
+ # Build path info
391
+ path_info = {}
392
+ if hasattr(page_class, "__routes__"):
393
+ for name in page_class.__routes__.keys():
394
+ path_info[name] = name == variant_name
395
+
396
+ # Build URL helper
397
+ url_helper = None
398
+ if hasattr(page_class, "__routes__"):
399
+ url_helper = URLHelper(page_class.__routes__)
400
+
401
+ # Create page instance
402
+ page = page_class(
403
+ request, params, query, path=path_info, url=url_helper
404
+ )
405
+
406
+ # Populate user if hook exists
407
+ if hasattr(self.app, "get_user"):
408
+ page.user = self.app.get_user(websocket)
409
+
410
+ self.connection_pages[websocket] = page
411
+
412
+ # Run on_load lifecycle hook
413
+ if hasattr(page, "on_load"):
414
+ if inspect.iscoroutinefunction(page.on_load):
415
+ await page.on_load()
416
+ else:
417
+ page.on_load()
418
+
419
+ # Render and send initial HTML
420
+ response = await page.render()
421
+ html = response.body.decode("utf-8")
422
+ await websocket.send_bytes(
423
+ msgpack.packb({"type": "update", "html": html})
424
+ )
425
+ return
426
+
427
+ # Parse new URL
428
+ from urllib.parse import parse_qs, urlparse
429
+
430
+ parsed_url = urlparse(path)
431
+ pathname = parsed_url.path
432
+ query_string = parsed_url.query
433
+
434
+ # Match route to get new params and variant
435
+ match = self.app.router.match(pathname)
436
+ if not match:
437
+ # Try custom 404 route
438
+ # This keeps the SPA alive instead of reloading
439
+ match = self.app.router.match("/404")
440
+
441
+ if match:
442
+ print(f"Relocate: Route not found for {pathname}, serving /404")
443
+ else:
444
+ # Try /__error__ fallback
445
+ match = self.app.router.match("/__error__")
446
+
447
+ if match:
448
+ print(
449
+ f"Relocate: Route not found for {pathname}, serving /__error__"
450
+ )
451
+ else:
452
+ # Fallback to generic ErrorPage if no custom 404
453
+ # We need to construct a bound ErrorPage class
454
+ print(
455
+ f"Relocate: Route not found for {pathname}, serving generic 404"
456
+ )
457
+ from pywire.runtime.error_page import ErrorPage
458
+
459
+ # Create a closure helper
460
+ class BoundErrorPage(ErrorPage):
461
+ def __init__(
462
+ self, request: Request, *args: Any, **kwargs: Any
463
+ ) -> None:
464
+ super().__init__(
465
+ request,
466
+ "404 Not Found",
467
+ f"The path '{pathname}' could not be found.",
468
+ )
469
+
470
+ match = (BoundErrorPage, {}, "main")
471
+
472
+ page_class, params, variant_name = match
473
+
474
+ # Reset page
475
+
476
+ if hasattr(page_class, "__routes__"):
477
+ pass
478
+
479
+ # print(f"Relocate: Loading page {page_class.__name__} for {pathname}")
480
+
481
+ # Create request object
482
+ from starlette.requests import Request
483
+
484
+ scope = dict(websocket.scope)
485
+ scope["type"] = "http"
486
+ scope["path"] = pathname
487
+ scope["raw_path"] = pathname.encode("ascii")
488
+ scope["query_string"] = (
489
+ query_string.encode("ascii") if query_string else b""
490
+ )
491
+ # Ensure minimal requirements for valid Request
492
+ if "headers" not in scope:
493
+ scope["headers"] = [(b"host", b"localhost")]
494
+ if "method" not in scope:
495
+ scope["method"] = "GET"
496
+ if "scheme" not in scope:
497
+ scope["scheme"] = "http"
498
+ if "server" not in scope:
499
+ scope["server"] = ("localhost", 80)
500
+ if "client" not in scope:
501
+ scope["client"] = ("127.0.0.1", 0)
502
+ request = Request(scope)
503
+
504
+ # Parse query
505
+ if query_string:
506
+ parsed = parse_qs(query_string)
507
+ query = {k: v[0] if len(v) == 1 else v for k, v in parsed.items()}
508
+ else:
509
+ query = {}
510
+
511
+ # Build path info
512
+ path_info = {}
513
+ if hasattr(page_class, "__routes__"):
514
+ for name in page_class.__routes__.keys():
515
+ path_info[name] = name == variant_name
516
+
517
+ # Build URL helper
518
+ from pywire.runtime.router import URLHelper
519
+
520
+ url_helper = None
521
+ if hasattr(page_class, "__routes__"):
522
+ url_helper = URLHelper(page_class.__routes__)
523
+
524
+ # Instantiate new page
525
+ new_page = page_class(
526
+ request, params, query, path=path_info, url=url_helper
527
+ )
528
+
529
+ # If this is an error page (match failed originally), inject error code
530
+ if not self.app.router.match(pathname):
531
+ new_page.error_code = 404
532
+
533
+ # Migrate persistent user state
534
+ new_page.user = getattr(page, "user", None)
535
+
536
+ # Replace page instance
537
+ self.connection_pages[websocket] = new_page
538
+
539
+ # Set update hook
540
+ async def broadcast_update() -> None:
541
+ up_response = await new_page.render(init=False)
542
+ up_html = up_response.body.decode("utf-8")
543
+ await websocket.send_bytes(
544
+ msgpack.packb({"type": "update", "html": up_html})
545
+ )
546
+
547
+ new_page._on_update = broadcast_update
548
+
549
+ # Run __on_load lifecycle hook
550
+ try:
551
+ if hasattr(new_page, "on_load"):
552
+ if inspect.iscoroutinefunction(new_page.on_load):
553
+ await new_page.on_load()
554
+ else:
555
+ new_page.on_load()
556
+
557
+ # Render and send HTML
558
+ response = await new_page.render()
559
+ html = response.body.decode("utf-8")
560
+
561
+ await websocket.send_bytes(
562
+ msgpack.packb({"type": "update", "html": html})
563
+ )
564
+ except Exception:
565
+ raise
566
+ except Exception as e:
567
+ # If relocation fails (e.g. 500 error), force a full reload
568
+ # This ensures the browser hits the server and gets the proper error page (or 500 page)
569
+ print(f"Error handling relocate: {e}", file=sys.stderr)
570
+ await websocket.send_bytes(msgpack.packb({"type": "reload"}))
571
+ finally:
572
+ log_callback_ctx.reset(token)
573
+
574
+ async def broadcast_reload(self) -> None:
575
+ """Broadcast reload to all clients, preserving state where possible.
576
+
577
+ For each connection with an existing page instance, attempts to:
578
+ 1. Create a new page instance from the updated class
579
+ 2. Migrate user state from old instance to new instance
580
+ 3. Re-render and send 'update' message
581
+ 4. Fall back to hard 'reload' if any step fails
582
+ """
583
+ if not self.active_connections:
584
+ return
585
+
586
+ disconnected = set()
587
+ for connection in list(self.active_connections):
588
+ try:
589
+ old_page = self.connection_pages.get(connection)
590
+ if old_page:
591
+ try:
592
+ # Get the current URL path from the old page's request
593
+ path = old_page.request.url.path
594
+
595
+ # Find the NEW page class from the router (which was just updated)
596
+ match = self.app.router.match(path)
597
+ if not match:
598
+ raise Exception(f"No route found for {path}")
599
+
600
+ new_page_class, params, variant_name = match
601
+
602
+ # Create new page instance with same context
603
+ new_page = new_page_class(
604
+ old_page.request,
605
+ params,
606
+ old_page.query,
607
+ path=old_page.path,
608
+ url=old_page.url,
609
+ )
610
+
611
+ # Migrate user state: copy all non-framework attributes
612
+ # Framework attrs to skip
613
+ skip_attrs = {
614
+ "request",
615
+ "params",
616
+ "query",
617
+ "path",
618
+ "url",
619
+ "user",
620
+ "errors",
621
+ "loading",
622
+ }
623
+ for attr, value in old_page.__dict__.items():
624
+ if attr not in skip_attrs and not attr.startswith("_"):
625
+ try:
626
+ setattr(new_page, attr, value)
627
+ except AttributeError:
628
+ pass # Read-only or property, skip
629
+
630
+ # Preserve user
631
+ new_page.user = old_page.user
632
+
633
+ # Update our reference
634
+ self.connection_pages[connection] = new_page
635
+
636
+ # Render with new code but preserved state
637
+ response = await new_page.render()
638
+ html = response.body.decode("utf-8")
639
+ await connection.send_bytes(
640
+ msgpack.packb({"type": "update", "html": html})
641
+ )
642
+ print(
643
+ f"PyWire: Hot reload (state preserved) for {type(new_page).__name__}"
644
+ )
645
+
646
+ except Exception as e:
647
+ # Anything failed, fall back to hard reload
648
+ print(
649
+ f"PyWire: Hot reload failed, falling back to hard reload: {e}"
650
+ )
651
+ import traceback
652
+
653
+ traceback.print_exc()
654
+ message_bytes = msgpack.packb({"type": "reload"})
655
+ await connection.send_bytes(message_bytes)
656
+ else:
657
+ # No page instance, do hard reload
658
+ await connection.send_bytes(msgpack.packb({"type": "reload"}))
659
+ except Exception:
660
+ disconnected.add(connection)
661
+
662
+ for conn in disconnected:
663
+ self.active_connections.discard(conn)
664
+ if conn in self.connection_pages:
665
+ del self.connection_pages[conn]