Skip to content

Commit 49bdda1

Browse files
authored
Use ASGI-Tools for HTTP/WS handling (#1268)
1 parent 1a221c8 commit 49bdda1

File tree

6 files changed

+71
-109
lines changed

6 files changed

+71
-109
lines changed

docs/source/about/changelog.rst

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Unreleased
3434
- :pull:`1113` - Renamed the ``use_location`` hook's ``search`` attribute to ``query_string``.
3535
- :pull:`1113` - Renamed the ``use_location`` hook's ``pathname`` attribute to ``path``.
3636
- :pull:`1113` - Renamed ``reactpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``.
37+
- :pull:`1113` - ``@reactpy/client`` now exports ``React`` and ``ReactDOM``.
3738
- :pull:`1263` - ReactPy no longer auto-converts ``snake_case`` props to ``camelCase``. It is now the responsibility of the user to ensure that props are in the correct format.
3839

3940
**Removed**

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependencies = [
3939
"lxml >=4",
4040
"servestatic >=3.0.0",
4141
"orjson >=3",
42+
"asgi-tools",
4243
]
4344
dynamic = ["version"]
4445
urls.Changelog = "https://reactpy.dev/docs/about/changelog.html"

src/reactpy/asgi/middleware.py

+55-38
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import Any
1212

1313
import orjson
14+
from asgi_tools import ResponseWebSocket
1415
from asgiref import typing as asgi_types
1516
from asgiref.compatibility import guarantee_single_callable
1617
from servestatic import ServeStaticASGI
@@ -26,6 +27,8 @@
2627
AsgiHttpApp,
2728
AsgiLifespanApp,
2829
AsgiWebsocketApp,
30+
AsgiWebsocketReceive,
31+
AsgiWebsocketSend,
2932
Connection,
3033
Location,
3134
ReactPyConfig,
@@ -153,41 +156,56 @@ async def __call__(
153156
send: asgi_types.ASGISendCallable,
154157
) -> None:
155158
"""ASGI app for rendering ReactPy Python components."""
156-
dispatcher: asyncio.Task[Any] | None = None
157-
recv_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
158-
159159
# Start a loop that handles ASGI websocket events
160-
while True:
161-
event = await receive()
162-
if event["type"] == "websocket.connect":
163-
await send(
164-
{"type": "websocket.accept", "subprotocol": None, "headers": []}
165-
)
166-
dispatcher = asyncio.create_task(
167-
self.run_dispatcher(scope, receive, send, recv_queue)
168-
)
169-
170-
elif event["type"] == "websocket.disconnect":
171-
if dispatcher:
172-
dispatcher.cancel()
173-
break
174-
175-
elif event["type"] == "websocket.receive" and event["text"]:
176-
queue_put_func = recv_queue.put(orjson.loads(event["text"]))
177-
await queue_put_func
178-
179-
async def run_dispatcher(
160+
async with ReactPyWebsocket(scope, receive, send, parent=self.parent) as ws: # type: ignore
161+
while True:
162+
# Wait for the webserver to notify us of a new event
163+
event: dict[str, Any] = await ws.receive(raw=True) # type: ignore
164+
165+
# If the event is a `receive` event, parse the message and send it to the rendering queue
166+
if event["type"] == "websocket.receive":
167+
msg: dict[str, str] = orjson.loads(event["text"])
168+
if msg.get("type") == "layout-event":
169+
await ws.rendering_queue.put(msg)
170+
else: # pragma: no cover
171+
await asyncio.to_thread(
172+
_logger.warning, f"Unknown message type: {msg.get('type')}"
173+
)
174+
175+
# If the event is a `disconnect` event, break the rendering loop and close the connection
176+
elif event["type"] == "websocket.disconnect":
177+
break
178+
179+
180+
class ReactPyWebsocket(ResponseWebSocket):
181+
def __init__(
180182
self,
181183
scope: asgi_types.WebSocketScope,
182-
receive: asgi_types.ASGIReceiveCallable,
183-
send: asgi_types.ASGISendCallable,
184-
recv_queue: asyncio.Queue[dict[str, Any]],
184+
receive: AsgiWebsocketReceive,
185+
send: AsgiWebsocketSend,
186+
parent: ReactPyMiddleware,
185187
) -> None:
186-
"""Asyncio background task that renders and transmits layout updates of ReactPy components."""
188+
super().__init__(scope=scope, receive=receive, send=send) # type: ignore
189+
self.scope = scope
190+
self.parent = parent
191+
self.rendering_queue: asyncio.Queue[dict[str, str]] = asyncio.Queue()
192+
self.dispatcher: asyncio.Task[Any] | None = None
193+
194+
async def __aenter__(self) -> ReactPyWebsocket:
195+
self.dispatcher = asyncio.create_task(self.run_dispatcher())
196+
return await super().__aenter__() # type: ignore
197+
198+
async def __aexit__(self, *_: Any) -> None:
199+
if self.dispatcher:
200+
self.dispatcher.cancel()
201+
await super().__aexit__() # type: ignore
202+
203+
async def run_dispatcher(self) -> None:
204+
"""Async background task that renders ReactPy components over a websocket."""
187205
try:
188206
# Determine component to serve by analyzing the URL and/or class parameters.
189207
if self.parent.multiple_root_components:
190-
url_match = re.match(self.parent.dispatcher_pattern, scope["path"])
208+
url_match = re.match(self.parent.dispatcher_pattern, self.scope["path"])
191209
if not url_match: # pragma: no cover
192210
raise RuntimeError("Could not find component in URL path.")
193211
dotted_path = url_match["dotted_path"]
@@ -203,10 +221,10 @@ async def run_dispatcher(
203221

204222
# Create a connection object by analyzing the websocket's query string.
205223
ws_query_string = urllib.parse.parse_qs(
206-
scope["query_string"].decode(), strict_parsing=True
224+
self.scope["query_string"].decode(), strict_parsing=True
207225
)
208226
connection = Connection(
209-
scope=scope,
227+
scope=self.scope,
210228
location=Location(
211229
path=ws_query_string.get("http_pathname", [""])[0],
212230
query_string=ws_query_string.get("http_query_string", [""])[0],
@@ -217,20 +235,19 @@ async def run_dispatcher(
217235
# Start the ReactPy component rendering loop
218236
await serve_layout(
219237
Layout(ConnectionContext(component(), value=connection)),
220-
lambda msg: send(
221-
{
222-
"type": "websocket.send",
223-
"text": orjson.dumps(msg).decode(),
224-
"bytes": None,
225-
}
226-
),
227-
recv_queue.get, # type: ignore
238+
self.send_json,
239+
self.rendering_queue.get, # type: ignore
228240
)
229241

230242
# Manually log exceptions since this function is running in a separate asyncio task.
231243
except Exception as error:
232244
await asyncio.to_thread(_logger.error, f"{error}\n{traceback.format_exc()}")
233245

246+
async def send_json(self, data: Any) -> None:
247+
return await self._send(
248+
{"type": "websocket.send", "text": orjson.dumps(data).decode()}
249+
)
250+
234251

235252
@dataclass
236253
class StaticFileApp:

src/reactpy/asgi/standalone.py

+11-26
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,13 @@
88
from logging import getLogger
99
from typing import Callable, Literal, cast, overload
1010

11+
from asgi_tools import ResponseHTML
1112
from asgiref import typing as asgi_types
1213
from typing_extensions import Unpack
1314

1415
from reactpy import html
1516
from reactpy.asgi.middleware import ReactPyMiddleware
16-
from reactpy.asgi.utils import (
17-
dict_to_byte_list,
18-
http_response,
19-
import_dotted_path,
20-
vdom_head_to_html,
21-
)
17+
from reactpy.asgi.utils import import_dotted_path, vdom_head_to_html
2218
from reactpy.types import (
2319
AsgiApp,
2420
AsgiHttpApp,
@@ -40,7 +36,7 @@ def __init__(
4036
self,
4137
root_component: RootComponentConstructor,
4238
*,
43-
http_headers: dict[str, str | int] | None = None,
39+
http_headers: dict[str, str] | None = None,
4440
html_head: VdomDict | None = None,
4541
html_lang: str = "en",
4642
**settings: Unpack[ReactPyConfig],
@@ -182,44 +178,33 @@ async def __call__(
182178

183179
# Response headers for `index.html` responses
184180
request_headers = dict(scope["headers"])
185-
response_headers: dict[str, str | int] = {
181+
response_headers: dict[str, str] = {
186182
"etag": self._etag,
187183
"last-modified": self._last_modified,
188184
"access-control-allow-origin": "*",
189185
"cache-control": "max-age=60, public",
190-
"content-length": len(self._cached_index_html),
186+
"content-length": str(len(self._cached_index_html)),
191187
"content-type": "text/html; charset=utf-8",
192188
**self.parent.extra_headers,
193189
}
194190

195191
# Browser is asking for the headers
196192
if scope["method"] == "HEAD":
197-
return await http_response(
198-
send=send,
199-
method=scope["method"],
200-
headers=dict_to_byte_list(response_headers),
201-
)
193+
response = ResponseHTML("", headers=response_headers)
194+
return await response(scope, receive, send) # type: ignore
202195

203196
# Browser already has the content cached
204197
if (
205198
request_headers.get(b"if-none-match") == self._etag.encode()
206199
or request_headers.get(b"if-modified-since") == self._last_modified.encode()
207200
):
208201
response_headers.pop("content-length")
209-
return await http_response(
210-
send=send,
211-
method=scope["method"],
212-
code=304,
213-
headers=dict_to_byte_list(response_headers),
214-
)
202+
response = ResponseHTML("", headers=response_headers, status_code=304)
203+
return await response(scope, receive, send) # type: ignore
215204

216205
# Send the index.html
217-
await http_response(
218-
send=send,
219-
method=scope["method"],
220-
message=self._cached_index_html,
221-
headers=dict_to_byte_list(response_headers),
222-
)
206+
response = ResponseHTML(self._cached_index_html, headers=response_headers)
207+
await response(scope, receive, send) # type: ignore
223208

224209
def process_index_html(self) -> None:
225210
"""Process the index.html and store the results in memory."""

src/reactpy/asgi/utils.py

-43
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
from importlib import import_module
66
from typing import Any
77

8-
from asgiref import typing as asgi_types
9-
108
from reactpy._option import Option
119
from reactpy.types import ReactPyConfig, VdomDict
1210
from reactpy.utils import vdom_to_html
@@ -55,18 +53,6 @@ def check_path(url_path: str) -> str: # pragma: no cover
5553
return ""
5654

5755

58-
def dict_to_byte_list(
59-
data: dict[str, str | int],
60-
) -> list[tuple[bytes, bytes]]:
61-
"""Convert a dictionary to a list of byte tuples."""
62-
result: list[tuple[bytes, bytes]] = []
63-
for key, value in data.items():
64-
new_key = key.encode()
65-
new_value = value.encode() if isinstance(value, str) else str(value).encode()
66-
result.append((new_key, new_value))
67-
return result
68-
69-
7056
def vdom_head_to_html(head: VdomDict) -> str:
7157
if isinstance(head, dict) and head.get("tagName") == "head":
7258
return vdom_to_html(head)
@@ -76,35 +62,6 @@ def vdom_head_to_html(head: VdomDict) -> str:
7662
)
7763

7864

79-
async def http_response(
80-
*,
81-
send: asgi_types.ASGISendCallable,
82-
method: str,
83-
code: int = 200,
84-
message: str = "",
85-
headers: Iterable[tuple[bytes, bytes]] = (),
86-
) -> None:
87-
"""Sends a HTTP response using the ASGI `send` API."""
88-
start_msg: asgi_types.HTTPResponseStartEvent = {
89-
"type": "http.response.start",
90-
"status": code,
91-
"headers": [*headers],
92-
"trailers": False,
93-
}
94-
body_msg: asgi_types.HTTPResponseBodyEvent = {
95-
"type": "http.response.body",
96-
"body": b"",
97-
"more_body": False,
98-
}
99-
100-
# Add the content type and body to everything other than a HEAD request
101-
if method != "HEAD":
102-
body_msg["body"] = message.encode()
103-
104-
await send(start_msg)
105-
await send(body_msg)
106-
107-
10865
def process_settings(settings: ReactPyConfig) -> None:
10966
"""Process the settings and return the final configuration."""
11067
from reactpy import config

tests/test_asgi/test_standalone.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
from collections.abc import MutableMapping
33

44
import pytest
5+
from asgi_tools import ResponseText
56
from asgiref.testing import ApplicationCommunicator
67
from requests import request
78

89
import reactpy
910
from reactpy import html
1011
from reactpy.asgi.standalone import ReactPy
11-
from reactpy.asgi.utils import http_response
1212
from reactpy.testing import BackendFixture, DisplayFixture, poll
1313
from reactpy.testing.common import REACTPY_TESTS_DEFAULT_TIMEOUT
1414
from reactpy.types import Connection, Location
@@ -180,7 +180,8 @@ async def custom_http_app(scope, receive, send) -> None:
180180
raise ValueError("Custom HTTP app received a non-HTTP scope")
181181

182182
rendered.current = True
183-
await http_response(send=send, method=scope["method"], message="Hello World")
183+
response = ResponseText("Hello World")
184+
await response(scope, receive, send)
184185

185186
scope = {
186187
"type": "http",

0 commit comments

Comments
 (0)