11import asyncio
22import logging
3- import mimetypes
4- import os
53import re
64import urllib .parse
75from collections .abc import Coroutine , Sequence
6+ from concurrent .futures import Future
7+ from importlib import import_module
88from pathlib import Path
99from threading import Thread
10+ from typing import Any , Callable
1011
1112import aiofiles
1213import orjson
1314from asgiref .compatibility import guarantee_single_callable
15+ from starlette .staticfiles import StaticFiles
1416
1517from reactpy .backend ._common import (
1618 CLIENT_BUILD_DIR ,
17- safe_join_path ,
1819 vdom_head_elements_to_html ,
1920)
2021from reactpy .backend .hooks import ConnectionContext
21- from reactpy .backend .mimetypes import MIME_TYPES
2222from reactpy .backend .types import Connection , Location
2323from reactpy .config import REACTPY_WEB_MODULES_DIR
2424from reactpy .core .layout import Layout
2525from reactpy .core .serve import serve_layout
2626from 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
3332def 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():
4242class 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
263298async 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- )
0 commit comments