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.
- 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 +889 -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/templates/error/404.html +11 -0
- pywire/templates/error/500.html +38 -0
- pywire/templates/error/base.html +207 -0
- pywire/templates/error/compile_error.html +31 -0
- pywire-0.1.0.dist-info/METADATA +50 -0
- pywire-0.1.0.dist-info/RECORD +104 -0
- pywire-0.1.0.dist-info/WHEEL +4 -0
- pywire-0.1.0.dist-info/entry_points.txt +2 -0
- pywire-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
|
|
3
|
+
from starlette.requests import Request
|
|
4
|
+
from starlette.responses import HTMLResponse
|
|
5
|
+
|
|
6
|
+
from pywire.runtime.page import BasePage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ErrorPage(BasePage):
|
|
10
|
+
"""Page used to display compilation errors."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, request: Request, error_title: str, error_detail: str):
|
|
13
|
+
# Initialize base directly without calling super().__init__ completely
|
|
14
|
+
# because we don't have all the normal params
|
|
15
|
+
self.request = request
|
|
16
|
+
self.error_title = error_title
|
|
17
|
+
self.error_detail = error_detail
|
|
18
|
+
|
|
19
|
+
async def render(self, init: bool = True) -> HTMLResponse:
|
|
20
|
+
"""Render the error page."""
|
|
21
|
+
from pywire.runtime.error_renderer import render_template
|
|
22
|
+
|
|
23
|
+
# Determine script URL (handled in app or passed here?)
|
|
24
|
+
# For now, default to dev script as ErrorPage is mostly used in dev/mixed?
|
|
25
|
+
# Actually app._get_client_script_url handles this logic, but we don't always have app ref here.
|
|
26
|
+
# But wait, ErrorPage is usually instantiated by app or similar.
|
|
27
|
+
# Let's check constructor. It takes request. We can get app from request.app usually if Starlette?
|
|
28
|
+
# request.app is available.
|
|
29
|
+
|
|
30
|
+
script_url = "/_pywire/static/pywire.core.min.js"
|
|
31
|
+
if hasattr(self.request, "app") and hasattr(
|
|
32
|
+
self.request.app, "_get_client_script_url"
|
|
33
|
+
):
|
|
34
|
+
# Use the private method if available (a bit hacky but correct for PyWire app)
|
|
35
|
+
# Or check debug mode directly
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
# Actually, simpler: check if we are in dev mode via request.app.state if set?
|
|
39
|
+
# The prompt mentioned "attach the correct script based on the environment".
|
|
40
|
+
# PyWire app sets self.app.state.pywire = self.
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
pywire_app = self.request.app.state.pywire
|
|
44
|
+
script_url = pywire_app._get_client_script_url()
|
|
45
|
+
except (AttributeError, KeyError):
|
|
46
|
+
# Fallback
|
|
47
|
+
script_url = "/_pywire/static/pywire.dev.min.js"
|
|
48
|
+
|
|
49
|
+
html_content = render_template(
|
|
50
|
+
"error/404.html",
|
|
51
|
+
{
|
|
52
|
+
"title": self.error_title,
|
|
53
|
+
"message": self.error_detail or "",
|
|
54
|
+
"script_url": script_url,
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return HTMLResponse(html_content)
|
|
59
|
+
|
|
60
|
+
async def handle_event(
|
|
61
|
+
self, handler_name: str, data: Dict[str, Any]
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
"""No-op for error page."""
|
|
64
|
+
return await self.render_update(init=False)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
|
|
3
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
4
|
+
|
|
5
|
+
# Initialize templating environment for internal error pages
|
|
6
|
+
_env = Environment(
|
|
7
|
+
loader=PackageLoader("pywire", "templates"),
|
|
8
|
+
autoescape=select_autoescape(["html", "xml"]),
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_template(template_name: str, context: Dict[str, Any]) -> str:
|
|
13
|
+
"""Render a Jinja2 template with the given context.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
template_name: Name of the template relative to src/pywire/templates/
|
|
17
|
+
context: Dictionary of variables to pass to the template
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Rendered HTML string
|
|
21
|
+
"""
|
|
22
|
+
template = _env.get_template(template_name)
|
|
23
|
+
return template.render(**context)
|
pywire/runtime/escape.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""HTML escaping utilities for XSS prevention."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def escape_html(value: Any) -> str:
|
|
7
|
+
"""Escape HTML special characters to prevent XSS.
|
|
8
|
+
|
|
9
|
+
Escapes: & < > "
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
value: Any value to escape (will be converted to string first)
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
HTML-escaped string safe for embedding in HTML content
|
|
16
|
+
"""
|
|
17
|
+
s = str(value)
|
|
18
|
+
return (
|
|
19
|
+
s.replace("&", "&")
|
|
20
|
+
.replace("<", "<")
|
|
21
|
+
.replace(">", ">")
|
|
22
|
+
.replace('"', """)
|
|
23
|
+
)
|
pywire/runtime/files.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class FileUpload:
|
|
6
|
+
"""Represents an uploaded file."""
|
|
7
|
+
|
|
8
|
+
filename: str
|
|
9
|
+
content_type: str
|
|
10
|
+
size: int
|
|
11
|
+
content: bytes
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def from_dict(cls, data: dict) -> "FileUpload":
|
|
15
|
+
"""Create from dictionary (e.g. from JSON payload)."""
|
|
16
|
+
import base64
|
|
17
|
+
|
|
18
|
+
# content might be base64 encoded string from client
|
|
19
|
+
content_data = data.get("content", b"")
|
|
20
|
+
if isinstance(content_data, str):
|
|
21
|
+
# assume base64 if it's a string, or raw content?
|
|
22
|
+
# Client usually sends data URL: "data:image/png;base64,....."
|
|
23
|
+
if content_data.startswith("data:"):
|
|
24
|
+
header, encoded = content_data.split(",", 1)
|
|
25
|
+
content_bytes = base64.b64decode(encoded)
|
|
26
|
+
else:
|
|
27
|
+
# Fallback or raw base64
|
|
28
|
+
try:
|
|
29
|
+
content_bytes = base64.b64decode(content_data)
|
|
30
|
+
except Exception:
|
|
31
|
+
content_bytes = content_data.encode("utf-8")
|
|
32
|
+
else:
|
|
33
|
+
content_bytes = content_data
|
|
34
|
+
|
|
35
|
+
return cls(
|
|
36
|
+
filename=data.get("name", "unknown"),
|
|
37
|
+
content_type=data.get("type", "application/octet-stream"),
|
|
38
|
+
size=data.get("size", 0),
|
|
39
|
+
content=content_bytes,
|
|
40
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from typing import Any, AsyncIterator
|
|
2
|
+
|
|
3
|
+
from pywire.core.wire import (
|
|
4
|
+
reset_render_context as _reset_render_context,
|
|
5
|
+
set_render_context as _set_render_context,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def ensure_async_iterator(iterable: Any) -> AsyncIterator[Any]:
|
|
10
|
+
"""
|
|
11
|
+
Ensure an iterable is an async iterator.
|
|
12
|
+
Handles both sync iterables (list, etc.) and async iterables.
|
|
13
|
+
"""
|
|
14
|
+
if hasattr(iterable, "__aiter__"):
|
|
15
|
+
async for item in iterable:
|
|
16
|
+
yield item
|
|
17
|
+
elif hasattr(iterable, "__iter__"):
|
|
18
|
+
for item in iterable:
|
|
19
|
+
yield item
|
|
20
|
+
else:
|
|
21
|
+
# Fallback or error?
|
|
22
|
+
# Maybe it's a generator?
|
|
23
|
+
# If it's not iterable at all, standard for loop raises TypeError.
|
|
24
|
+
# We should probably let it raise, or wrapping non-iterable?
|
|
25
|
+
for item in iterable: # This will raise if not iterable
|
|
26
|
+
yield item
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def set_render_context(page: Any, region_id: str) -> Any:
|
|
30
|
+
return _set_render_context(page, region_id)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def reset_render_context(token: Any) -> None:
|
|
34
|
+
_reset_render_context(token)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def unwrap_wire(value: Any) -> Any:
|
|
38
|
+
"""Return the underlying value for single-value wires; passthrough otherwise."""
|
|
39
|
+
from pywire.core.wire import wire
|
|
40
|
+
|
|
41
|
+
if isinstance(value, wire):
|
|
42
|
+
if value.__dict__.get("_namespace"):
|
|
43
|
+
return value
|
|
44
|
+
return value.value
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def render_attrs(
|
|
49
|
+
defined_attrs: dict[str, Any], spread_attrs: dict[str, Any] | None = None
|
|
50
|
+
) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Merge and render HTML attributes.
|
|
53
|
+
defined_attrs: Attributes defined in the template (explicitly).
|
|
54
|
+
spread_attrs: Attributes passed to the component (implicit/explicit spread).
|
|
55
|
+
Rules:
|
|
56
|
+
- spread_attrs override defined_attrs, EXCEPT:
|
|
57
|
+
- class: merged (appended).
|
|
58
|
+
- style: merged (concatenated).
|
|
59
|
+
"""
|
|
60
|
+
if not spread_attrs:
|
|
61
|
+
spread_attrs = {}
|
|
62
|
+
|
|
63
|
+
# Copy defined_attrs to start
|
|
64
|
+
final_attrs = defined_attrs.copy()
|
|
65
|
+
|
|
66
|
+
for k, v in spread_attrs.items():
|
|
67
|
+
if k == "class" and "class" in final_attrs:
|
|
68
|
+
final_attrs["class"] = f"{final_attrs['class']} {v}".strip()
|
|
69
|
+
elif k == "style" and "style" in final_attrs:
|
|
70
|
+
# Naive style merge: concat with semicolon if missing
|
|
71
|
+
s1 = str(final_attrs["style"]).strip()
|
|
72
|
+
s2 = str(v).strip()
|
|
73
|
+
if s1 and not s1.endswith(";"):
|
|
74
|
+
s1 += ";"
|
|
75
|
+
final_attrs["style"] = f"{s1} {s2}".strip()
|
|
76
|
+
else:
|
|
77
|
+
final_attrs[k] = v
|
|
78
|
+
|
|
79
|
+
# Render
|
|
80
|
+
parts = []
|
|
81
|
+
for k, v in final_attrs.items():
|
|
82
|
+
if v is True: # bool attr
|
|
83
|
+
parts.append(f" {k}")
|
|
84
|
+
elif v is False or v is None:
|
|
85
|
+
continue
|
|
86
|
+
else:
|
|
87
|
+
# Escape HTML special characters in attribute values for XSS prevention
|
|
88
|
+
val = (
|
|
89
|
+
str(v)
|
|
90
|
+
.replace("&", "&")
|
|
91
|
+
.replace("<", "<")
|
|
92
|
+
.replace(">", ">")
|
|
93
|
+
.replace('"', """)
|
|
94
|
+
)
|
|
95
|
+
parts.append(f' {k}="{val}"')
|
|
96
|
+
|
|
97
|
+
return "".join(parts)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""HTTP transport handler for PyWire fallback."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import msgpack
|
|
11
|
+
from starlette.requests import Request
|
|
12
|
+
from starlette.responses import Response
|
|
13
|
+
|
|
14
|
+
from pywire.runtime.page import BasePage
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class HTTPSession:
|
|
19
|
+
"""Represents an HTTP polling session."""
|
|
20
|
+
|
|
21
|
+
session_id: str
|
|
22
|
+
path: str
|
|
23
|
+
page: Optional[BasePage] = None
|
|
24
|
+
pending_updates: List[Dict[str, Any]] = field(default_factory=list)
|
|
25
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
26
|
+
last_poll: datetime = field(default_factory=datetime.now)
|
|
27
|
+
update_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
28
|
+
|
|
29
|
+
def is_expired(self, timeout_seconds: int = 300) -> bool:
|
|
30
|
+
"""Check if session has expired."""
|
|
31
|
+
return datetime.now() - self.last_poll > timedelta(seconds=timeout_seconds)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HTTPTransportHandler:
|
|
35
|
+
"""Handles HTTP long-polling connections for PyWire fallback transport."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, app: Any) -> None:
|
|
38
|
+
self.app = app
|
|
39
|
+
self.sessions: Dict[str, HTTPSession] = {}
|
|
40
|
+
self._cleanup_task: Optional[asyncio.Task] = None
|
|
41
|
+
|
|
42
|
+
def start_cleanup_task(self) -> None:
|
|
43
|
+
"""Start background task to clean up expired sessions."""
|
|
44
|
+
if self._cleanup_task is None:
|
|
45
|
+
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
|
46
|
+
|
|
47
|
+
async def _cleanup_loop(self) -> None:
|
|
48
|
+
"""Periodically clean up expired sessions."""
|
|
49
|
+
while True:
|
|
50
|
+
await asyncio.sleep(60) # Check every minute
|
|
51
|
+
expired = [
|
|
52
|
+
sid for sid, session in self.sessions.items() if session.is_expired()
|
|
53
|
+
]
|
|
54
|
+
for sid in expired:
|
|
55
|
+
del self.sessions[sid]
|
|
56
|
+
if expired:
|
|
57
|
+
print(f"PyWire: Cleaned up {len(expired)} expired HTTP sessions")
|
|
58
|
+
|
|
59
|
+
async def create_session(self, request: Request) -> Response:
|
|
60
|
+
"""Create a new HTTP polling session."""
|
|
61
|
+
try:
|
|
62
|
+
body = await request.body()
|
|
63
|
+
if not body:
|
|
64
|
+
# Allow empty body for initial connect
|
|
65
|
+
data = {}
|
|
66
|
+
else:
|
|
67
|
+
try:
|
|
68
|
+
data = msgpack.unpackb(body, raw=False)
|
|
69
|
+
except Exception:
|
|
70
|
+
# Fallback to JSON for compatibility if needed, or error
|
|
71
|
+
import json
|
|
72
|
+
|
|
73
|
+
data = json.loads(body)
|
|
74
|
+
|
|
75
|
+
path = data.get("path", "/")
|
|
76
|
+
except Exception:
|
|
77
|
+
path = "/"
|
|
78
|
+
|
|
79
|
+
session_id = str(uuid.uuid4())
|
|
80
|
+
session = HTTPSession(session_id=session_id, path=path)
|
|
81
|
+
|
|
82
|
+
# Try to instantiate the page for this session
|
|
83
|
+
match = self.app.router.match(path)
|
|
84
|
+
if match:
|
|
85
|
+
page_class, params, variant_name = match
|
|
86
|
+
query = dict(request.query_params)
|
|
87
|
+
|
|
88
|
+
# Build path info dict
|
|
89
|
+
path_info = {}
|
|
90
|
+
if hasattr(page_class, "__routes__"):
|
|
91
|
+
for name in page_class.__routes__.keys():
|
|
92
|
+
path_info[name] = name == variant_name
|
|
93
|
+
elif hasattr(page_class, "__route__"):
|
|
94
|
+
path_info["main"] = True
|
|
95
|
+
|
|
96
|
+
# Build URL helper
|
|
97
|
+
from pywire.runtime.router import URLHelper
|
|
98
|
+
|
|
99
|
+
url_helper = None
|
|
100
|
+
if hasattr(page_class, "__routes__"):
|
|
101
|
+
url_helper = URLHelper(page_class.__routes__)
|
|
102
|
+
|
|
103
|
+
session.page = page_class(
|
|
104
|
+
request, params, query, path=path_info, url=url_helper
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if hasattr(self.app, "get_user"):
|
|
108
|
+
session.page.user = self.app.get_user(request)
|
|
109
|
+
|
|
110
|
+
# Run load hook
|
|
111
|
+
if hasattr(session.page, "on_load"):
|
|
112
|
+
if inspect.iscoroutinefunction(session.page.on_load):
|
|
113
|
+
await session.page.on_load()
|
|
114
|
+
else:
|
|
115
|
+
session.page.on_load()
|
|
116
|
+
|
|
117
|
+
self.sessions[session_id] = session
|
|
118
|
+
self.start_cleanup_task()
|
|
119
|
+
|
|
120
|
+
return Response(
|
|
121
|
+
msgpack.packb({"sessionId": session_id}), media_type="application/x-msgpack"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def poll(self, request: Request) -> Response:
|
|
125
|
+
"""Long-poll for updates."""
|
|
126
|
+
session_id = request.query_params.get("session")
|
|
127
|
+
|
|
128
|
+
if not session_id or session_id not in self.sessions:
|
|
129
|
+
return Response(
|
|
130
|
+
msgpack.packb({"error": "Session not found"}),
|
|
131
|
+
status_code=404,
|
|
132
|
+
media_type="application/x-msgpack",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
session = self.sessions[session_id]
|
|
136
|
+
session.last_poll = datetime.now()
|
|
137
|
+
|
|
138
|
+
# Check if updates already pending
|
|
139
|
+
if session.pending_updates:
|
|
140
|
+
updates = session.pending_updates.copy()
|
|
141
|
+
session.pending_updates.clear()
|
|
142
|
+
session.update_event.clear()
|
|
143
|
+
return Response(msgpack.packb(updates), media_type="application/x-msgpack")
|
|
144
|
+
|
|
145
|
+
# Wait for updates with timeout
|
|
146
|
+
try:
|
|
147
|
+
await asyncio.wait_for(session.update_event.wait(), timeout=30.0)
|
|
148
|
+
|
|
149
|
+
# Event set, get updates
|
|
150
|
+
updates = session.pending_updates.copy()
|
|
151
|
+
session.pending_updates.clear()
|
|
152
|
+
session.update_event.clear()
|
|
153
|
+
return Response(msgpack.packb(updates), media_type="application/x-msgpack")
|
|
154
|
+
|
|
155
|
+
except asyncio.TimeoutError:
|
|
156
|
+
# Return empty array on timeout
|
|
157
|
+
return Response(msgpack.packb([]), media_type="application/x-msgpack")
|
|
158
|
+
|
|
159
|
+
async def handle_event(self, request: Request) -> Response:
|
|
160
|
+
"""Handle an event from an HTTP client."""
|
|
161
|
+
session_id = request.headers.get("X-PyWire-Session")
|
|
162
|
+
|
|
163
|
+
if not session_id or session_id not in self.sessions:
|
|
164
|
+
return Response(
|
|
165
|
+
msgpack.packb({"error": "Session not found"}),
|
|
166
|
+
status_code=404,
|
|
167
|
+
media_type="application/x-msgpack",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
session = self.sessions[session_id]
|
|
171
|
+
session.last_poll = datetime.now()
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
body = await request.body()
|
|
175
|
+
data = msgpack.unpackb(body, raw=False)
|
|
176
|
+
handler_name = data.get("handler")
|
|
177
|
+
event_data = data.get("data", {})
|
|
178
|
+
|
|
179
|
+
if session.page is None:
|
|
180
|
+
# Recreate page if needed
|
|
181
|
+
match = self.app.router.match(session.path)
|
|
182
|
+
if not match:
|
|
183
|
+
return Response(
|
|
184
|
+
msgpack.packb({"error": "Page not found"}),
|
|
185
|
+
status_code=404,
|
|
186
|
+
media_type="application/x-msgpack",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
page_class, params, variant_name = match
|
|
190
|
+
query = dict(request.query_params)
|
|
191
|
+
|
|
192
|
+
# Build path info dict
|
|
193
|
+
path_info = {}
|
|
194
|
+
if hasattr(page_class, "__routes__"):
|
|
195
|
+
for name in page_class.__routes__.keys():
|
|
196
|
+
path_info[name] = name == variant_name
|
|
197
|
+
elif hasattr(page_class, "__route__"):
|
|
198
|
+
path_info["main"] = True
|
|
199
|
+
|
|
200
|
+
# Build URL helper
|
|
201
|
+
from pywire.runtime.router import URLHelper
|
|
202
|
+
|
|
203
|
+
url_helper = None
|
|
204
|
+
if hasattr(page_class, "__routes__"):
|
|
205
|
+
url_helper = URLHelper(page_class.__routes__)
|
|
206
|
+
|
|
207
|
+
session.page = page_class(
|
|
208
|
+
request, params, query, path=path_info, url=url_helper
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Run load hook
|
|
212
|
+
if hasattr(session.page, "on_load"):
|
|
213
|
+
if inspect.iscoroutinefunction(session.page.on_load):
|
|
214
|
+
await session.page.on_load()
|
|
215
|
+
else:
|
|
216
|
+
session.page.on_load()
|
|
217
|
+
|
|
218
|
+
# Dispatch event
|
|
219
|
+
update = await session.page.handle_event(handler_name, event_data)
|
|
220
|
+
|
|
221
|
+
if isinstance(update, Response):
|
|
222
|
+
html = bytes(update.body).decode("utf-8")
|
|
223
|
+
payload: Dict[str, Any] = {"type": "update", "html": html}
|
|
224
|
+
elif isinstance(update, dict) and update.get("type") == "regions":
|
|
225
|
+
payload = {"type": "update", "regions": update.get("regions", [])}
|
|
226
|
+
elif isinstance(update, dict) and update.get("type") == "full":
|
|
227
|
+
payload = {"type": "update", "html": update.get("html", "")}
|
|
228
|
+
else:
|
|
229
|
+
payload = {"type": "error", "error": "Invalid update payload"}
|
|
230
|
+
|
|
231
|
+
return Response(
|
|
232
|
+
msgpack.packb(payload),
|
|
233
|
+
media_type="application/x-msgpack",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
except Exception as e:
|
|
237
|
+
return Response(
|
|
238
|
+
msgpack.packb({"type": "error", "error": str(e)}),
|
|
239
|
+
status_code=500,
|
|
240
|
+
media_type="application/x-msgpack",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def queue_update(self, session_id: str, update: Dict[str, Any]) -> None:
|
|
244
|
+
"""Queue an update to be sent to a specific session."""
|
|
245
|
+
if session_id in self.sessions:
|
|
246
|
+
self.sessions[session_id].pending_updates.append(update)
|
|
247
|
+
self.sessions[session_id].update_event.set()
|
|
248
|
+
|
|
249
|
+
def broadcast_reload(self) -> None:
|
|
250
|
+
"""Queue reload message to all sessions."""
|
|
251
|
+
for session in self.sessions.values():
|
|
252
|
+
session.pending_updates.append({"type": "reload"})
|
|
253
|
+
session.update_event.set()
|