Skip to content

Commit 24fb817

Browse files
committed
use starlette for static files
1 parent 93d7c03 commit 24fb817

File tree

2 files changed

+110
-131
lines changed

2 files changed

+110
-131
lines changed
Lines changed: 90 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,37 @@
11
import asyncio
22
import logging
3-
import mimetypes
4-
import os
53
import re
64
import urllib.parse
75
from collections.abc import Coroutine, Sequence
6+
from concurrent.futures import Future
7+
from importlib import import_module
88
from pathlib import Path
99
from threading import Thread
10+
from typing import Any, Callable
1011

1112
import aiofiles
1213
import orjson
1314
from asgiref.compatibility import guarantee_single_callable
15+
from starlette.staticfiles import StaticFiles
1416

1517
from reactpy.backend._common import (
1618
CLIENT_BUILD_DIR,
17-
safe_join_path,
1819
vdom_head_elements_to_html,
1920
)
2021
from reactpy.backend.hooks import ConnectionContext
21-
from reactpy.backend.mimetypes import MIME_TYPES
2222
from reactpy.backend.types import Connection, Location
2323
from reactpy.config import REACTPY_WEB_MODULES_DIR
2424
from reactpy.core.layout import Layout
2525
from reactpy.core.serve import serve_layout
2626
from reactpy.core.types import ComponentConstructor, VdomDict
27-
from concurrent.futures import Future
2827

2928
_logger = logging.getLogger(__name__)
3029
_backhaul_loop = asyncio.new_event_loop()
3130

3231

3332
def start_backhaul_loop():
34-
"""Starts the asyncio event loop that will perform component rendering tasks."""
33+
"""Starts the asyncio event loop that will perform component rendering
34+
tasks."""
3535
asyncio.set_event_loop(_backhaul_loop)
3636
_backhaul_loop.run_forever()
3737

@@ -42,7 +42,7 @@ def start_backhaul_loop():
4242
class ReactPy:
4343
def __init__(
4444
self,
45-
app_or_component: ComponentConstructor | Coroutine,
45+
app_or_component: ComponentConstructor | Callable[..., Coroutine],
4646
*,
4747
dispatcher_path: str = "^reactpy/([^/]+)/?",
4848
js_modules_path: str | None = "^reactpy/modules/([^/]+)/?",
@@ -62,35 +62,43 @@ def __init__(
6262
self.block_size = block_size
6363

6464
# Internal attributes (not using the same name as a kwarg)
65-
self.component: re.Pattern = (
66-
app_or_component
67-
if isinstance(app_or_component, ComponentConstructor)
68-
else None
69-
)
70-
self.user_app: re.Pattern = (
65+
self.user_app: Callable[..., Coroutine] | None = (
7166
guarantee_single_callable(app_or_component)
72-
if not self.component and asyncio.iscoroutinefunction(app_or_component)
67+
if asyncio.iscoroutinefunction(app_or_component)
7368
else None
7469
)
70+
self.component: ComponentConstructor | None = (
71+
None if self.user_app else app_or_component
72+
)
7573
self.all_paths: re.Pattern = re.compile(
7674
"|".join(
7775
path for path in [dispatcher_path, js_modules_path, static_path] if path
7876
)
7977
)
8078
self.dispatcher: Future | asyncio.Task | None = None
8179
self._cached_index_html: str = ""
80+
self._static_file_server: StaticFiles | None = None
81+
self._js_module_server: StaticFiles | None = None
8282
self.connected: bool = False
83+
# TODO: Remove this setting from ReactPy config
84+
self.js_modules_dir: Path | None = REACTPY_WEB_MODULES_DIR.current
8385

8486
# Validate the arguments
8587
if not self.component and not self.user_app:
8688
raise TypeError(
87-
"The first argument to ReactPy(...) must be a component or an ASGI application."
89+
"The first argument to ReactPy(...) must be a component or an "
90+
"ASGI application."
8891
)
8992
if self.backhaul_thread and not _backhaul_thread.is_alive():
9093
_backhaul_thread.start()
9194

92-
async def __call__(self, scope, receive, send) -> None:
93-
"""The ASGI callable. This determines whether ReactPy should route the the
95+
async def __call__(
96+
self,
97+
scope: dict[str, Any],
98+
receive: Callable[..., Coroutine],
99+
send: Callable[..., Coroutine],
100+
) -> None:
101+
"""The ASGI callable. This determines whether ReactPy should route the
94102
request to ourselves or to the user application."""
95103
# Determine if ReactPy should handle the request
96104
if not self.user_app or re.match(self.all_paths, scope["path"]):
@@ -100,10 +108,14 @@ async def __call__(self, scope, receive, send) -> None:
100108
# Serve the user's application
101109
await self.user_app(scope, receive, send)
102110

103-
async def reactpy_app(self, scope, receive, send) -> None:
104-
"""Determine what type of request this is and route it to the appropriate
105-
ReactPy ASGI sub-application."""
106-
111+
async def reactpy_app(
112+
self,
113+
scope: dict[str, Any],
114+
receive: Callable[..., Coroutine],
115+
send: Callable[..., Coroutine],
116+
) -> None:
117+
"""Determine what type of request this is and route it to the
118+
appropriate ReactPy ASGI sub-application."""
107119
# Only HTTP and WebSocket requests are supported
108120
if scope["type"] not in {"http", "websocket"}:
109121
return
@@ -120,7 +132,7 @@ async def reactpy_app(self, scope, receive, send) -> None:
120132

121133
# JS modules app
122134
if self.js_modules_path and re.match(self.js_modules_path, scope["path"]):
123-
await self.js_modules_app(scope, receive, send)
135+
await self.js_module_app(scope, receive, send)
124136
return
125137

126138
# Static file app
@@ -133,7 +145,12 @@ async def reactpy_app(self, scope, receive, send) -> None:
133145
await self.standalone_app(scope, receive, send)
134146
return
135147

136-
async def component_dispatch_app(self, scope, receive, send) -> None:
148+
async def component_dispatch_app(
149+
self,
150+
scope: dict[str, Any],
151+
receive: Callable[..., Coroutine],
152+
send: Callable[..., Coroutine],
153+
) -> None:
137154
"""ASGI app for rendering ReactPy Python components."""
138155
while True:
139156
event = await receive()
@@ -161,45 +178,50 @@ async def component_dispatch_app(self, scope, receive, send) -> None:
161178
else:
162179
await recv_queue_put
163180

164-
async def js_modules_app(self, scope, receive, send) -> None:
181+
async def js_module_app(
182+
self,
183+
scope: dict[str, Any],
184+
receive: Callable[..., Coroutine],
185+
send: Callable[..., Coroutine],
186+
) -> None:
165187
"""ASGI app for ReactPy web modules."""
166-
if not REACTPY_WEB_MODULES_DIR.current:
188+
if not self.js_modules_dir:
167189
raise RuntimeError("No web modules directory configured.")
168-
169-
# Make sure the user hasn't tried to escape the web modules directory
170-
try:
171-
abs_file_path = safe_join_path(
172-
REACTPY_WEB_MODULES_DIR.current,
173-
re.match(self.js_modules_path, scope["path"])[1],
190+
if not self.js_modules_path:
191+
raise RuntimeError(
192+
"Web modules cannot be served without defining `js_module_path`."
174193
)
175-
except ValueError:
176-
await http_response(scope, send, 403, "Forbidden")
177-
return
194+
if not self._js_module_server:
195+
self._js_module_server = StaticFiles(directory=self.js_modules_dir)
178196

179-
# Serve the file
180-
await file_response(scope, send, abs_file_path, self.block_size)
197+
await self._js_module_server(scope, receive, send)
181198

182-
async def static_file_app(self, scope, receive, send) -> None:
199+
async def static_file_app(
200+
self,
201+
scope: dict[str, Any],
202+
receive: Callable[..., Coroutine],
203+
send: Callable[..., Coroutine],
204+
) -> None:
183205
"""ASGI app for ReactPy static files."""
184206
if not self.static_dir:
185207
raise RuntimeError(
186208
"Static files cannot be served without defining `static_dir`."
187209
)
188-
189-
# Make sure the user hasn't tried to escape the static directory
190-
try:
191-
abs_file_path = safe_join_path(
192-
self.static_dir,
193-
re.match(self.static_path, scope["path"])[1],
210+
if not self.static_path:
211+
raise RuntimeError(
212+
"Static files cannot be served without defining `static_path`."
194213
)
195-
except ValueError:
196-
await http_response(scope, send, 403, "Forbidden")
197-
return
214+
if not self._static_file_server:
215+
self._static_file_server = StaticFiles(directory=self.static_dir)
198216

199-
# Serve the file
200-
await file_response(scope, send, abs_file_path, self.block_size)
217+
await self._static_file_server(scope, receive, send)
201218

202-
async def standalone_app(self, scope, receive, send) -> None:
219+
async def standalone_app(
220+
self,
221+
scope: dict[str, Any],
222+
receive: Callable[..., Coroutine],
223+
send: Callable[..., Coroutine],
224+
) -> None:
203225
"""ASGI app for ReactPy standalone mode."""
204226
file_path = CLIENT_BUILD_DIR / "index.html"
205227
if not self._cached_index_html:
@@ -221,10 +243,23 @@ async def standalone_app(self, scope, receive, send) -> None:
221243
],
222244
)
223245

224-
async def run_dispatcher(self, scope, receive, send):
246+
async def run_dispatcher(
247+
self,
248+
scope: dict[str, Any],
249+
receive: Callable[..., Coroutine],
250+
send: Callable[..., Coroutine],
251+
) -> None:
225252
# If in standalone mode, serve the user provided component.
226253
# In middleware mode, get the component from the URL.
227-
component = self.component or re.match(self.dispatch_path, scope["path"])[1]
254+
component = self.component
255+
if not component:
256+
url_path = re.match(self.dispatch_path, scope["path"])
257+
if not url_path:
258+
raise RuntimeError("Could not find component in URL path.")
259+
dotted_path = url_path[1]
260+
module_str, component_str = dotted_path.rsplit(".", 1)
261+
module = import_module(module_str)
262+
component = getattr(module, component_str)
228263
parsed_url = urllib.parse.urlparse(scope["path"])
229264
self.recv_queue: asyncio.Queue = asyncio.Queue()
230265

@@ -251,18 +286,18 @@ async def run_dispatcher(self, scope, receive, send):
251286
)
252287

253288

254-
def send_json(send) -> None:
289+
def send_json(send: Callable) -> Callable[..., Coroutine]:
255290
"""Use orjson to send JSON over an ASGI websocket."""
256291

257-
async def _send_json(value) -> None:
292+
async def _send_json(value: Any) -> None:
258293
await send({"type": "websocket.send", "text": orjson.dumps(value)})
259294

260295
return _send_json
261296

262297

263298
async def http_response(
264-
scope,
265-
send,
299+
scope: dict[str, Any],
300+
send: Callable[..., Coroutine],
266301
code: int,
267302
message: str,
268303
content_type: bytes = b"text/plain",
@@ -279,79 +314,3 @@ async def http_response(
279314
# Head requests don't need a body
280315
if scope["method"] != "HEAD":
281316
await send({"type": "http.response.body", "body": message.encode()})
282-
283-
284-
async def file_response(scope, send, file_path: Path, block_size: int) -> None:
285-
"""Send a file in chunks."""
286-
# Make sure the file exists
287-
if not await asyncio.to_thread(os.path.exists, file_path):
288-
await http_response(scope, send, 404, "File not found.")
289-
return
290-
291-
# Make sure it's a file
292-
if not await asyncio.to_thread(os.path.isfile, file_path):
293-
await http_response(scope, send, 400, "Not a file.")
294-
return
295-
296-
# Check if the file is already cached by the client
297-
etag = await header_val(scope, b"etag")
298-
modification_time = await asyncio.to_thread(os.path.getmtime, file_path)
299-
if etag and etag != modification_time:
300-
await http_response(scope, send, 304, "Not modified.")
301-
return
302-
303-
# Get the file's MIME type
304-
mime_type = (
305-
MIME_TYPES.get(file_path.rsplit(".")[1], None)
306-
# Fallback to guess_type to allow for the user to define custom MIME types on their system
307-
or (await asyncio.to_thread(mimetypes.guess_type, file_path, strict=False))[0]
308-
)
309-
if mime_type is None:
310-
mime_type = "text/plain"
311-
_logger.error(
312-
f"Could not determine MIME type for {file_path}. Defaulting to 'text/plain'."
313-
)
314-
315-
# Send the file in chunks
316-
file_size = await asyncio.to_thread(os.path.getsize, file_path)
317-
async with aiofiles.open(file_path, "rb") as file_handle:
318-
await send(
319-
{
320-
"type": "http.response.start",
321-
"status": 200,
322-
"headers": [
323-
(b"content-type", mime_type.encode()),
324-
(b"etag", modification_time),
325-
(b"content-length", file_size),
326-
],
327-
}
328-
)
329-
330-
# Head requests don't need a body
331-
if scope["method"] != "HEAD":
332-
while True:
333-
chunk = await file_handle.read(block_size)
334-
more_body = bool(chunk)
335-
await send(
336-
{
337-
"type": "http.response.body",
338-
"body": chunk,
339-
"more_body": more_body,
340-
}
341-
)
342-
if not more_body:
343-
break
344-
345-
346-
async def header_val(
347-
scope: dict, key: str, default: str | int | None = None
348-
) -> str | int | None:
349-
"""Get a value from a scope's headers."""
350-
return await anext(
351-
(
352-
value.decode()
353-
for header_key, value in scope["headers"]
354-
if header_key == key.encode()
355-
),
356-
default,
357-
)

src/py/reactpy/reactpy/backend/mimetypes.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
We ship our own mime types to ensure consistent behavior across platforms.
33
This dictionary is based on: https://github.com/micnic/mime.json
44
"""
5+
import mimetypes
6+
import os
7+
import typing
8+
9+
from starlette import responses
510

611
MIME_TYPES = {
712
"123": "application/vnd.lotus-1-2-3",
@@ -1206,3 +1211,18 @@
12061211
"zirz": "application/vnd.zul",
12071212
"zmm": "application/vnd.handheld-entertainment+xml",
12081213
}
1214+
1215+
1216+
def guess_type(
1217+
url: typing.Union[str, "os.PathLike[str]"],
1218+
strict: bool = True,
1219+
):
1220+
"""Mime type checker that prefers our predefined types over the built-in
1221+
mimetypes module."""
1222+
mime_type, encoding = mimetypes.guess_type(url, strict)
1223+
1224+
return (MIME_TYPES.get(str(url).rsplit(".")[1]) or mime_type, encoding)
1225+
1226+
1227+
# Monkey patch starlette's mime types
1228+
responses.guess_type = guess_type

0 commit comments

Comments
 (0)