Skip to content

Commit 252489e

Browse files
committed
everything besides component_dispatch_app
1 parent 778057d commit 252489e

File tree

2 files changed

+1462
-0
lines changed

2 files changed

+1462
-0
lines changed
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import logging
2+
import mimetypes
3+
import os
4+
import re
5+
from pathlib import Path
6+
from typing import Sequence
7+
8+
import aiofiles
9+
from asgiref.compatibility import guarantee_single_callable
10+
11+
from reactpy.backend._common import (
12+
CLIENT_BUILD_DIR,
13+
traversal_safe_path,
14+
vdom_head_elements_to_html,
15+
)
16+
from reactpy.backend.mimetypes import DEFAULT_MIME_TYPES
17+
from reactpy.config import REACTPY_WEB_MODULES_DIR
18+
from reactpy.core.types import VdomDict
19+
20+
DEFAULT_STATIC_PATH = f"{os.getcwd()}/static"
21+
DEFAULT_BLOCK_SIZE = 8192
22+
_logger = logging.getLogger(__name__)
23+
24+
25+
class ReactPy:
26+
def __init__(
27+
self,
28+
application=None,
29+
dispatcher_path: str = "^reactpy/stream/([^/]+)/?",
30+
js_modules_path: str | None = "^reactpy/modules/([^/]+)/?",
31+
static_path: str | None = "^reactpy/static/([^/]+)/?",
32+
static_dir: str | None = DEFAULT_STATIC_PATH,
33+
head: Sequence[VdomDict] | VdomDict | str = "",
34+
) -> None:
35+
self.user_app = guarantee_single_callable(application)
36+
self.dispatch_path = re.compile(dispatcher_path)
37+
self.js_modules_path = re.compile(js_modules_path)
38+
self.static_path = re.compile(static_path)
39+
self.static_dir = static_dir
40+
self.all_paths = re.compile(
41+
"|".join(
42+
path for path in [dispatcher_path, js_modules_path, static_path] if path
43+
)
44+
)
45+
self.head = vdom_head_elements_to_html(head)
46+
self._cached_index_html = ""
47+
48+
async def __call__(self, scope, receive, send) -> None:
49+
"""The ASGI callable. This determines whether ReactPy should route the the
50+
request to ourselves or to the user application."""
51+
52+
# Determine if ReactPy should handle the request
53+
if not self.user_app or re.match(self.all_paths, scope["path"]):
54+
# Dispatch a Python component
55+
if scope["type"] == "websocket" and re.match(
56+
self.dispatch_path, scope["path"]
57+
):
58+
await self.component_dispatch_app(scope, receive, send)
59+
return
60+
61+
# User tried to use an unsupported HTTP method
62+
if scope["method"] not in ("GET", "HEAD"):
63+
await simple_response(
64+
scope, send, status=405, content="Method Not Allowed"
65+
)
66+
return
67+
68+
# Serve a JS web module
69+
if scope["type"] == "http" and re.match(
70+
self.js_modules_path, scope["path"]
71+
):
72+
await self.js_modules_app(scope, receive, send)
73+
return
74+
75+
# Serve a static file
76+
if scope["type"] == "http" and re.match(self.static_path, scope["path"]):
77+
await self.static_file_app(scope, receive, send)
78+
return
79+
80+
# Serve index.html
81+
if scope["type"] == "http":
82+
await self.index_html_app(scope, receive, send)
83+
return
84+
85+
# Serve the user's application
86+
else:
87+
await self.user_app(scope, receive, send)
88+
89+
async def component_dispatch_app(self, scope, receive, send) -> None:
90+
"""The ASGI application for ReactPy Python components."""
91+
92+
async def js_modules_app(self, scope, receive, send) -> None:
93+
"""The ASGI application for ReactPy web modules."""
94+
95+
if not REACTPY_WEB_MODULES_DIR.current:
96+
raise RuntimeError("No web modules directory configured")
97+
98+
# Get the relative file path from the URL
99+
file_url_path = re.match(self.js_modules_path, scope["path"])[1]
100+
101+
# Make sure the user hasn't tried to escape the web modules directory
102+
try:
103+
file_path = traversal_safe_path(
104+
REACTPY_WEB_MODULES_DIR.current,
105+
REACTPY_WEB_MODULES_DIR.current,
106+
file_url_path,
107+
)
108+
except ValueError:
109+
await simple_response(send, 403, "Forbidden")
110+
return
111+
112+
# Serve the file
113+
await file_response(scope, send, file_path)
114+
115+
async def static_file_app(self, scope, receive, send) -> None:
116+
"""The ASGI application for ReactPy static files."""
117+
118+
if self.static_dir is None:
119+
raise RuntimeError("No static directory configured")
120+
121+
# Get the relative file path from the URL
122+
file_url_path = re.match(self.static_path, scope["path"])[1]
123+
124+
# Make sure the user hasn't tried to escape the static directory
125+
try:
126+
file_path = traversal_safe_path(
127+
self.static_dir, self.static_dir, file_url_path
128+
)
129+
except ValueError:
130+
await simple_response(send, 403, "Forbidden")
131+
return
132+
133+
# Serve the file
134+
await file_response(scope, send, file_path)
135+
136+
async def index_html_app(self, scope, receive, send) -> None:
137+
"""The ASGI application for ReactPy index.html."""
138+
139+
# TODO: We want to respect the if-modified-since header, but currently can't
140+
# due to the fact that our HTML is not statically rendered.
141+
file_path = CLIENT_BUILD_DIR / "index.html"
142+
if not self._cached_index_html:
143+
async with aiofiles.open(file_path, "rb") as file_handle:
144+
self._cached_index_html = str(await file_handle.read()).format(
145+
__head__=self.head
146+
)
147+
148+
# Head requests don't need a body
149+
if scope["method"] == "HEAD":
150+
await simple_response(
151+
send,
152+
200,
153+
"",
154+
content_type=b"text/html",
155+
headers=[(b"cache-control", b"no-cache")],
156+
)
157+
return
158+
159+
# Send the index.html
160+
await simple_response(
161+
send,
162+
200,
163+
self._cached_index_html,
164+
content_type=b"text/html",
165+
headers=[(b"cache-control", b"no-cache")],
166+
)
167+
168+
169+
async def simple_response(
170+
send,
171+
code: int,
172+
message: str,
173+
content_type: bytes = b"text/plain",
174+
headers: Sequence = (),
175+
) -> None:
176+
"""Send a simple response."""
177+
178+
await send(
179+
{
180+
"type": "http.response.start",
181+
"status": code,
182+
"headers": [(b"content-type", content_type, *headers)],
183+
}
184+
)
185+
await send({"type": "http.response.body", "body": message.encode()})
186+
187+
188+
async def file_response(scope, send, file_path: Path) -> None:
189+
"""Send a file in chunks."""
190+
191+
# Make sure the file exists
192+
if not os.path.exists(file_path):
193+
await simple_response(send, 404, "File not found.")
194+
return
195+
196+
# Make sure it's a file
197+
if not os.path.isfile(file_path):
198+
await simple_response(send, 400, "Not a file.")
199+
return
200+
201+
# Check if the file is already cached by the client
202+
modified_since = await get_val_from_header(scope, b"if-modified-since")
203+
if modified_since and modified_since > os.path.getmtime(file_path):
204+
await simple_response(send, 304, "Not modified.")
205+
return
206+
207+
# Get the file's MIME type
208+
mime_type = (
209+
DEFAULT_MIME_TYPES.get(file_path.rsplit(".")[1], None)
210+
or mimetypes.guess_type(file_path, strict=False)[0]
211+
)
212+
if mime_type is None:
213+
mime_type = "text/plain"
214+
_logger.error(f"Could not determine MIME type for {file_path}.")
215+
216+
# Send the file in chunks
217+
async with aiofiles.open(file_path, "rb") as file_handle:
218+
await send(
219+
{
220+
"type": "http.response.start",
221+
"status": 200,
222+
"headers": [
223+
(b"content-type", mime_type.encode()),
224+
(b"last-modified", str(os.path.getmtime(file_path)).encode()),
225+
],
226+
}
227+
)
228+
229+
# Head requests don't need a body
230+
if scope["method"] == "HEAD":
231+
return
232+
233+
while True:
234+
chunk = await file_handle.read(DEFAULT_BLOCK_SIZE)
235+
more_body = bool(chunk)
236+
await send(
237+
{
238+
"type": "http.response.body",
239+
"body": chunk,
240+
"more_body": more_body,
241+
}
242+
)
243+
if not more_body:
244+
break
245+
246+
247+
async def get_val_from_header(
248+
scope: dict, key: str, default: str | None = None
249+
) -> str | None:
250+
"""Get a value from a scope's headers."""
251+
252+
return await anext(
253+
(
254+
value.decode()
255+
for header_key, value in scope["headers"]
256+
if header_key == key.encode()
257+
),
258+
default,
259+
)

0 commit comments

Comments
 (0)