pywire 0.1.1__py3-none-any.whl → 0.1.2__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.
- pywire/__init__.py +2 -0
- pywire/cli/__init__.py +1 -0
- pywire/cli/generators.py +48 -0
- pywire/cli/main.py +309 -0
- pywire/cli/tui.py +563 -0
- pywire/cli/validate.py +26 -0
- pywire/client/.prettierignore +8 -0
- pywire/client/.prettierrc +7 -0
- pywire/client/build.mjs +73 -0
- pywire/client/eslint.config.js +46 -0
- pywire/client/package.json +39 -0
- pywire/client/pnpm-lock.yaml +2971 -0
- pywire/client/src/core/app.ts +263 -0
- pywire/client/src/core/dom-updater.test.ts +78 -0
- pywire/client/src/core/dom-updater.ts +321 -0
- pywire/client/src/core/index.ts +5 -0
- pywire/client/src/core/transport-manager.test.ts +179 -0
- pywire/client/src/core/transport-manager.ts +159 -0
- pywire/client/src/core/transports/base.ts +122 -0
- pywire/client/src/core/transports/http.ts +142 -0
- pywire/client/src/core/transports/index.ts +13 -0
- pywire/client/src/core/transports/websocket.ts +97 -0
- pywire/client/src/core/transports/webtransport.ts +149 -0
- pywire/client/src/dev/dev-app.ts +93 -0
- pywire/client/src/dev/error-trace.test.ts +97 -0
- pywire/client/src/dev/error-trace.ts +76 -0
- pywire/client/src/dev/index.ts +4 -0
- pywire/client/src/dev/status-overlay.ts +63 -0
- pywire/client/src/events/handler.test.ts +318 -0
- pywire/client/src/events/handler.ts +454 -0
- pywire/client/src/pywire.core.ts +22 -0
- pywire/client/src/pywire.dev.ts +27 -0
- pywire/client/tsconfig.json +17 -0
- pywire/client/vitest.config.ts +15 -0
- pywire/compiler/__init__.py +6 -0
- pywire/compiler/ast_nodes.py +304 -0
- pywire/compiler/attributes/__init__.py +6 -0
- pywire/compiler/attributes/base.py +24 -0
- pywire/compiler/attributes/conditional.py +37 -0
- pywire/compiler/attributes/events.py +55 -0
- pywire/compiler/attributes/form.py +37 -0
- pywire/compiler/attributes/loop.py +75 -0
- pywire/compiler/attributes/reactive.py +34 -0
- pywire/compiler/build.py +28 -0
- pywire/compiler/build_artifacts.py +342 -0
- pywire/compiler/codegen/__init__.py +5 -0
- pywire/compiler/codegen/attributes/__init__.py +6 -0
- pywire/compiler/codegen/attributes/base.py +19 -0
- pywire/compiler/codegen/attributes/events.py +35 -0
- pywire/compiler/codegen/directives/__init__.py +6 -0
- pywire/compiler/codegen/directives/base.py +16 -0
- pywire/compiler/codegen/directives/path.py +53 -0
- pywire/compiler/codegen/generator.py +2341 -0
- pywire/compiler/codegen/template.py +2178 -0
- pywire/compiler/directives/__init__.py +7 -0
- pywire/compiler/directives/base.py +20 -0
- pywire/compiler/directives/component.py +33 -0
- pywire/compiler/directives/context.py +93 -0
- pywire/compiler/directives/layout.py +49 -0
- pywire/compiler/directives/no_spa.py +24 -0
- pywire/compiler/directives/path.py +71 -0
- pywire/compiler/directives/props.py +88 -0
- pywire/compiler/exceptions.py +19 -0
- pywire/compiler/interpolation/__init__.py +6 -0
- pywire/compiler/interpolation/base.py +28 -0
- pywire/compiler/interpolation/jinja.py +272 -0
- pywire/compiler/parser.py +750 -0
- pywire/compiler/paths.py +29 -0
- pywire/compiler/preprocessor.py +43 -0
- pywire/core/wire.py +119 -0
- pywire/py.typed +0 -0
- pywire/runtime/__init__.py +7 -0
- pywire/runtime/aioquic_server.py +194 -0
- pywire/runtime/app.py +901 -0
- pywire/runtime/compile_error_page.py +195 -0
- pywire/runtime/debug.py +203 -0
- pywire/runtime/dev_server.py +434 -0
- pywire/runtime/dev_server.py.broken +268 -0
- pywire/runtime/error_page.py +64 -0
- pywire/runtime/error_renderer.py +23 -0
- pywire/runtime/escape.py +23 -0
- pywire/runtime/files.py +40 -0
- pywire/runtime/helpers.py +97 -0
- pywire/runtime/http_transport.py +253 -0
- pywire/runtime/loader.py +272 -0
- pywire/runtime/logging.py +72 -0
- pywire/runtime/page.py +384 -0
- pywire/runtime/pydantic_integration.py +52 -0
- pywire/runtime/router.py +229 -0
- pywire/runtime/server.py +25 -0
- pywire/runtime/style_collector.py +31 -0
- pywire/runtime/upload_manager.py +76 -0
- pywire/runtime/validation.py +449 -0
- pywire/runtime/websocket.py +665 -0
- pywire/runtime/webtransport_handler.py +195 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/METADATA +1 -1
- pywire-0.1.2.dist-info/RECORD +104 -0
- pywire-0.1.1.dist-info/RECORD +0 -9
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/WHEEL +0 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/entry_points.txt +0 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/licenses/LICENSE +0 -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]
|