Skip to content

Commit 3d97430

Browse files
committed
refactoring
1 parent daaa235 commit 3d97430

File tree

2 files changed

+108
-128
lines changed

2 files changed

+108
-128
lines changed

src/js/app/index.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import { app } from "./src/index";
77
app(document.getElementById("app"));
88
</script>
9-
<!-- we replace this with user-provided head elements -->
109
{__head__}
1110
</head>
1211
<body>

src/py/reactpy/reactpy/backend/asgi.py

Lines changed: 108 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def __init__(
5151
)
5252
if not self.component and not self.user_app:
5353
raise TypeError(
54-
"The first argument to `ReactPy` must be a component or an ASGI application."
54+
"The first argument to ReactPy(...) must be a component or an ASGI application."
5555
)
5656
self.dispatch_path = re.compile(dispatcher_path)
5757
self.js_modules_path = re.compile(js_modules_path) if js_modules_path else None
@@ -64,93 +64,63 @@ def __init__(
6464
)
6565
self.head = vdom_head_elements_to_html(head)
6666
self._cached_index_html = ""
67+
self.connected = False
6768

6869
async def __call__(self, scope, receive, send) -> None:
6970
"""The ASGI callable. This determines whether ReactPy should route the the
7071
request to ourselves or to the user application."""
71-
7272
# Determine if ReactPy should handle the request
7373
if not self.user_app or re.match(self.all_paths, scope["path"]):
74-
# Dispatch a Python component
75-
if scope["type"] == "websocket" and re.match(
76-
self.dispatch_path, scope["path"]
77-
):
78-
await self.component_dispatch_app(scope, receive, send)
79-
return
80-
81-
# User tried to use an unsupported HTTP method
82-
if scope["type"] == "http" and scope["method"] not in ("GET", "HEAD"):
83-
await simple_response(
84-
scope, send, status=405, content="Method Not Allowed"
85-
)
86-
return
87-
88-
# Route requests to our JS web module app
89-
if (
90-
scope["type"] == "http"
91-
and self.js_modules_path
92-
and re.match(self.js_modules_path, scope["path"])
93-
):
94-
await self.js_modules_app(scope, receive, send)
95-
return
96-
97-
# Route requests to our static file server app
98-
if (
99-
scope["type"] == "http"
100-
and self.static_path
101-
and re.match(self.static_path, scope["path"])
102-
):
103-
await self.static_file_app(scope, receive, send)
104-
return
105-
106-
# Route all other requests to serve a component (user is in standalone mode)
107-
if scope["type"] == "http" and self.component:
108-
await self.index_html_app(scope, receive, send)
109-
return
74+
await self.reactpy_app(scope, receive, send)
75+
return
11076

11177
# Serve the user's application
112-
if self.user_app:
113-
await self.user_app(scope, receive, send)
114-
return
78+
await self.user_app(scope, receive, send)
11579

11680
_logger.error("ReactPy appears to be misconfigured. Request not handled.")
11781

118-
async def component_dispatch_app(self, scope, receive, send) -> None:
119-
"""The ASGI application for ReactPy Python components."""
82+
async def reactpy_app(self, scope, receive, send) -> None:
83+
"""Determine what type of request this is and route it to the appropriate
84+
ReactPy ASGI sub-application."""
12085

121-
parsed_url = urllib.parse.urlparse(scope["path"])
86+
# Only HTTP and WebSocket requests are supported
87+
if scope["type"] not in {"http", "websocket"}:
88+
return
12289

123-
# If in standalone mode, serve the user provided component.
124-
# In middleware mode, get the component from the URL.
125-
component = self.component or re.match(self.dispatch_path, scope["path"])[1]
90+
# Dispatch a Python component
91+
if scope["type"] == "websocket" and re.match(self.dispatch_path, scope["path"]):
92+
await self.component_dispatch_app(scope, receive, send)
93+
return
94+
95+
# Only HTTP GET and HEAD requests are supported
96+
if scope["method"] not in {"GET", "HEAD"}:
97+
await http_response(scope, send, 405, "Method Not Allowed")
98+
return
99+
100+
# JS modules app
101+
if self.js_modules_path and re.match(self.js_modules_path, scope["path"]):
102+
await self.js_modules_app(scope, receive, send)
103+
return
104+
105+
# Static file app
106+
if self.static_path and re.match(self.static_path, scope["path"]):
107+
await self.static_file_app(scope, receive, send)
108+
return
109+
110+
# Standalone app: Serve a single component using index.html
111+
if self.component:
112+
await self.standalone_app(scope, receive, send)
113+
return
126114

115+
async def component_dispatch_app(self, scope, receive, send) -> None:
116+
"""ASGI app for rendering ReactPy Python components."""
127117
while True:
128118
event = await receive()
129119

130-
if event["type"] == "websocket.connect":
120+
if event["type"] == "websocket.connect" and not self.connected:
121+
self.connected = True
131122
await send({"type": "websocket.accept"})
132-
self.recv_queue: asyncio.Queue = asyncio.Queue()
133-
await serve_layout(
134-
Layout(
135-
ConnectionContext(
136-
component(),
137-
value=Connection(
138-
scope=scope,
139-
location=Location(
140-
parsed_url.path,
141-
f"?{parsed_url.query}" if parsed_url.query else "",
142-
),
143-
carrier={
144-
"scope": scope,
145-
"send": send,
146-
"receive": receive,
147-
},
148-
),
149-
)
150-
),
151-
send_json(send),
152-
self.recv_queue.get,
153-
)
123+
await self.run_dispatcher(scope, receive, send)
154124

155125
if event["type"] == "websocket.disconnect":
156126
break
@@ -159,8 +129,7 @@ async def component_dispatch_app(self, scope, receive, send) -> None:
159129
await self.recv_queue.put(orjson.loads(event["text"]))
160130

161131
async def js_modules_app(self, scope, receive, send) -> None:
162-
"""The ASGI application for ReactPy web modules."""
163-
132+
"""ASGI app for ReactPy web modules."""
164133
if not REACTPY_WEB_MODULES_DIR.current:
165134
raise RuntimeError("No web modules directory configured.")
166135

@@ -172,15 +141,14 @@ async def js_modules_app(self, scope, receive, send) -> None:
172141
re.match(self.js_modules_path, scope["path"])[1],
173142
)
174143
except ValueError:
175-
await simple_response(send, 403, "Forbidden")
144+
await http_response(scope, send, 403, "Forbidden")
176145
return
177146

178147
# Serve the file
179148
await file_response(scope, send, abs_file_path)
180149

181150
async def static_file_app(self, scope, receive, send) -> None:
182-
"""The ASGI application for ReactPy static files."""
183-
151+
"""ASGI app for ReactPy static files."""
184152
if not self.static_dir:
185153
raise RuntimeError(
186154
"Static files cannot be served without defining `static_dir`."
@@ -193,42 +161,61 @@ async def static_file_app(self, scope, receive, send) -> None:
193161
re.match(self.static_path, scope["path"])[1],
194162
)
195163
except ValueError:
196-
await simple_response(send, 403, "Forbidden")
164+
await http_response(scope, send, 403, "Forbidden")
197165
return
198166

199167
# Serve the file
200168
await file_response(scope, send, abs_file_path)
201169

202-
async def index_html_app(self, scope, receive, send) -> None:
203-
"""The ASGI application for ReactPy index.html."""
204-
205-
# TODO: We want to respect the if-modified-since header, but currently can't
206-
# due to the fact that our HTML is not statically rendered.
170+
async def standalone_app(self, scope, receive, send) -> None:
171+
"""ASGI app for ReactPy standalone mode."""
207172
file_path = CLIENT_BUILD_DIR / "index.html"
208173
if not self._cached_index_html:
209174
async with aiofiles.open(file_path, "rb") as file_handle:
210175
self._cached_index_html = str(await file_handle.read()).format(
211176
__head__=self.head
212177
)
213178

214-
# Head requests don't need a body
215-
if scope["method"] == "HEAD":
216-
await simple_response(
217-
send,
218-
200,
219-
"",
220-
content_type=b"text/html",
221-
headers=[(b"cache-control", b"no-cache")],
222-
)
223-
return
224-
225179
# Send the index.html
226-
await simple_response(
180+
await http_response(
181+
scope,
227182
send,
228183
200,
229184
self._cached_index_html,
230185
content_type=b"text/html",
231-
headers=[(b"cache-control", b"no-cache")],
186+
headers=[
187+
(b"content-length", len(self._cached_index_html)),
188+
(b"etag", hash(self._cached_index_html)),
189+
],
190+
)
191+
192+
async def run_dispatcher(self, scope, receive, send):
193+
# If in standalone mode, serve the user provided component.
194+
# In middleware mode, get the component from the URL.
195+
component = self.component or re.match(self.dispatch_path, scope["path"])[1]
196+
parsed_url = urllib.parse.urlparse(scope["path"])
197+
self.recv_queue: asyncio.Queue = asyncio.Queue()
198+
199+
await serve_layout(
200+
Layout(
201+
ConnectionContext(
202+
component(),
203+
value=Connection(
204+
scope=scope,
205+
location=Location(
206+
parsed_url.path,
207+
f"?{parsed_url.query}" if parsed_url.query else "",
208+
),
209+
carrier={
210+
"scope": scope,
211+
"send": send,
212+
"receive": receive,
213+
},
214+
),
215+
)
216+
),
217+
send_json(send),
218+
self.recv_queue.get,
232219
)
233220

234221

@@ -241,44 +228,44 @@ async def _send_json(value) -> None:
241228
return _send_json
242229

243230

244-
async def simple_response(
231+
async def http_response(
232+
scope,
245233
send,
246234
code: int,
247235
message: str,
248236
content_type: bytes = b"text/plain",
249237
headers: Sequence = (),
250238
) -> None:
251239
"""Send a simple response."""
252-
253240
await send(
254241
{
255242
"type": "http.response.start",
256243
"status": code,
257244
"headers": [(b"content-type", content_type, *headers)],
258245
}
259246
)
260-
await send({"type": "http.response.body", "body": message.encode()})
247+
# Head requests don't need a body
248+
if scope["method"] != "HEAD":
249+
await send({"type": "http.response.body", "body": message.encode()})
261250

262251

263252
async def file_response(scope, send, file_path: Path) -> None:
264253
"""Send a file in chunks."""
265-
266254
# Make sure the file exists
267255
if not await asyncio.to_thread(os.path.exists, file_path):
268-
await simple_response(send, 404, "File not found.")
256+
await http_response(scope, send, 404, "File not found.")
269257
return
270258

271259
# Make sure it's a file
272260
if not await asyncio.to_thread(os.path.isfile, file_path):
273-
await simple_response(send, 400, "Not a file.")
261+
await http_response(scope, send, 400, "Not a file.")
274262
return
275263

276264
# Check if the file is already cached by the client
277-
etag = await get_val_from_header(scope, b"ETag")
278-
if etag and etag != await asyncio.to_thread(
279-
os.path.getmtime, file_path
280-
):
281-
await simple_response(send, 304, "Not modified.")
265+
etag = await get_val_from_header(scope, b"etag")
266+
modification_time = await asyncio.to_thread(os.path.getmtime, file_path)
267+
if etag and etag != modification_time:
268+
await http_response(scope, send, 304, "Not modified.")
282269
return
283270

284271
# Get the file's MIME type
@@ -294,46 +281,40 @@ async def file_response(scope, send, file_path: Path) -> None:
294281
)
295282

296283
# Send the file in chunks
284+
file_size = await asyncio.to_thread(os.path.getsize, file_path)
297285
async with aiofiles.open(file_path, "rb") as file_handle:
298286
await send(
299287
{
300288
"type": "http.response.start",
301289
"status": 200,
302290
"headers": [
303291
(b"content-type", mime_type.encode()),
304-
(
305-
b"ETag",
306-
str(
307-
await asyncio.to_thread(os.path.getmtime, file_path)
308-
).encode(),
309-
),
292+
(b"etag", modification_time),
293+
(b"content-length", file_size),
310294
],
311295
}
312296
)
313297

314298
# Head requests don't need a body
315-
if scope["method"] == "HEAD":
316-
return
317-
318-
while True:
319-
chunk = await file_handle.read(DEFAULT_BLOCK_SIZE)
320-
more_body = bool(chunk)
321-
await send(
322-
{
323-
"type": "http.response.body",
324-
"body": chunk,
325-
"more_body": more_body,
326-
}
327-
)
328-
if not more_body:
329-
break
299+
if scope["method"] != "HEAD":
300+
while True:
301+
chunk = await file_handle.read(DEFAULT_BLOCK_SIZE)
302+
more_body = bool(chunk)
303+
await send(
304+
{
305+
"type": "http.response.body",
306+
"body": chunk,
307+
"more_body": more_body,
308+
}
309+
)
310+
if not more_body:
311+
break
330312

331313

332314
async def get_val_from_header(
333315
scope: dict, key: str, default: str | None = None
334316
) -> str | None:
335317
"""Get a value from a scope's headers."""
336-
337318
return await anext(
338319
(
339320
value.decode()

0 commit comments

Comments
 (0)