1+ from __future__ import annotations
2+
13import asyncio
24import logging
35import re
46import traceback
57import urllib .parse
68from collections .abc import Coroutine , Iterable
7- from concurrent . futures import Future
9+ from dataclasses import dataclass
810from pathlib import Path
911from typing import Any , Callable
1012
1820from reactpy .core .hooks import ConnectionContext
1921from reactpy .core .layout import Layout
2022from reactpy .core .serve import serve_layout
21- from reactpy .core . types import ComponentType
23+ from reactpy .types import RootComponentConstructor
2224
2325_logger = logging .getLogger (__name__ )
2426
2527
2628class ReactPyMiddleware :
2729 _asgi_single_callable = True
28- servestatic_static : ServeStaticASGI | None = None
29- servestatic_web_modules : ServeStaticASGI | None = None
3030 single_root_component : bool = False
31- root_component : ComponentType | None = None
31+ root_component : RootComponentConstructor | None = None
3232
3333 def __init__ (
3434 self ,
@@ -40,7 +40,7 @@ def __init__(
4040 web_modules_dir : Path | None = None ,
4141 ) -> None :
4242 """Configure the ASGI app. Anything initialized in this method will be shared across all future requests."""
43- # Configure class attributes
43+ # URL path attributes
4444 self .path_prefix = normalize_url_path (path_prefix )
4545 self .dispatcher_path = f"/{ self .path_prefix } /"
4646 self .web_modules_path = f"/{ self .path_prefix } /modules/"
@@ -50,19 +50,27 @@ def __init__(
5050 )
5151 self .js_modules_pattern = re .compile (f"^{ self .web_modules_path } .*" )
5252 self .static_pattern = re .compile (f"^{ self .static_path } .*" )
53- self . web_modules_dir = web_modules_dir or REACTPY_WEB_MODULES_DIR . current
54- self . static_dir = Path ( __file__ ). parent . parent / "static"
53+
54+ # Component attributes
5555 self .user_app = guarantee_single_callable (app )
5656 self .component_dotted_paths = set (root_components )
57- self .components : dict [str , ComponentType ] = import_components (
57+ self .components : dict [str , RootComponentConstructor ] = import_components (
5858 self .component_dotted_paths
5959 )
6060
61+ # Directory attributes
62+ self .web_modules_dir = web_modules_dir or REACTPY_WEB_MODULES_DIR .current
63+ self .static_dir = Path (__file__ ).parent .parent / "static"
6164 if self .web_modules_dir != REACTPY_WEB_MODULES_DIR .current :
6265 REACTPY_WEB_MODULES_DIR .set_current (self .web_modules_dir )
6366
64- # Validate the arguments
65- reason = check_path (self .path_prefix )
67+ # Sub-applications
68+ self .component_dispatch_app = ComponentDispatchApp (parent = self )
69+ self .static_file_app = StaticFileApp (parent = self )
70+ self .web_modules_app = WebModuleApp (parent = self )
71+
72+ # Validate the configuration
73+ reason = check_path (path_prefix )
6674 if reason :
6775 raise ValueError (f"Invalid `path_prefix`. { reason } " )
6876
@@ -78,25 +86,39 @@ async def __call__(
7886 if scope ["type" ] == "websocket" and self .match_dispatch_path (scope ):
7987 return await self .component_dispatch_app (scope , receive , send )
8088
81- # URL routing for ReactPy web modules
82- if scope ["type" ] == "http" and re .match (self .js_modules_pattern , scope ["path" ]):
83- return await self .web_module_app (scope , receive , send )
84-
8589 # URL routing for ReactPy static files
86- if scope ["type" ] == "http" and re . match ( self .static_pattern , scope [ "path" ] ):
90+ if scope ["type" ] == "http" and self .match_static_path ( scope ):
8791 return await self .static_file_app (scope , receive , send )
8892
93+ # URL routing for ReactPy web modules
94+ if scope ["type" ] == "http" and self .match_web_modules_path (scope ):
95+ return await self .web_modules_app (scope , receive , send )
96+
8997 # Serve the user's application
9098 await self .user_app (scope , receive , send )
9199
92- async def component_dispatch_app (
100+ def match_dispatch_path (self , scope : dict ) -> bool :
101+ return bool (re .match (self .dispatcher_pattern , scope ["path" ]))
102+
103+ def match_static_path (self , scope : dict ) -> bool :
104+ return bool (re .match (self .static_pattern , scope ["path" ]))
105+
106+ def match_web_modules_path (self , scope : dict ) -> bool :
107+ return bool (re .match (self .js_modules_pattern , scope ["path" ]))
108+
109+
110+ @dataclass
111+ class ComponentDispatchApp :
112+ parent : ReactPyMiddleware
113+
114+ async def __call__ (
93115 self ,
94116 scope : dict [str , Any ],
95117 receive : Callable [..., Coroutine ],
96118 send : Callable [..., Coroutine ],
97119 ) -> None :
98120 """ASGI app for rendering ReactPy Python components."""
99- dispatcher : Future | asyncio .Task | None = None
121+ dispatcher : asyncio .Task | None = None
100122 recv_queue : asyncio .Queue = asyncio .Queue ()
101123
102124 # Start a loop that handles ASGI websocket events
@@ -117,52 +139,6 @@ async def component_dispatch_app(
117139 queue_put_func = recv_queue .put (orjson .loads (event ["text" ]))
118140 await queue_put_func
119141
120- async def web_module_app (
121- self ,
122- scope : dict [str , Any ],
123- receive : Callable [..., Coroutine ],
124- send : Callable [..., Coroutine ],
125- ) -> None :
126- """ASGI app for ReactPy web modules."""
127- if not self .web_modules_dir :
128- await asyncio .to_thread (
129- _logger .info ,
130- "Tried to serve web module without a configured directory." ,
131- )
132- return await self .user_app (scope , receive , send )
133-
134- if not self .servestatic_web_modules :
135- self .servestatic_web_modules = ServeStaticASGI (
136- self .user_app ,
137- root = self .web_modules_dir ,
138- prefix = self .web_modules_path ,
139- autorefresh = True ,
140- )
141-
142- return await self .servestatic_web_modules (scope , receive , send )
143-
144- async def static_file_app (
145- self ,
146- scope : dict [str , Any ],
147- receive : Callable [..., Coroutine ],
148- send : Callable [..., Coroutine ],
149- ) -> None :
150- """ASGI app for ReactPy static files."""
151- # If no static directory is configured, serve the user's application
152- if not self .static_dir :
153- await asyncio .to_thread (
154- _logger .info ,
155- "Tried to serve static file without a configured directory." ,
156- )
157- return await self .user_app (scope , receive , send )
158-
159- if not self .servestatic_static :
160- self .servestatic_static = ServeStaticASGI (
161- self .user_app , root = self .static_dir , prefix = self .static_path
162- )
163-
164- return await self .servestatic_static (scope , receive , send )
165-
166142 async def run_dispatcher (
167143 self ,
168144 scope : dict [str , Any ],
@@ -172,20 +148,23 @@ async def run_dispatcher(
172148 ) -> None :
173149 # Get the component from the URL.
174150 try :
175- if not self .single_root_component :
176- url_match = re .match (self .dispatcher_pattern , scope ["path" ])
151+ if not self .parent . single_root_component :
152+ url_match = re .match (self .parent . dispatcher_pattern , scope ["path" ])
177153 if not url_match :
178154 raise RuntimeError ("Could not find component in URL path." )
179155 dotted_path = url_match [1 ]
180- component = self .components [dotted_path ]
156+ component = self .parent .components [dotted_path ]
157+ elif self .parent .root_component :
158+ component = self .parent .root_component
181159 else :
182- component = self .root_component
160+ raise RuntimeError ("No root component provided." )
161+
183162 parsed_url = urllib .parse .urlparse (scope ["path" ])
184163
185164 await serve_layout (
186165 Layout ( # type: ignore
187166 ConnectionContext (
188- component ,
167+ component () ,
189168 value = Connection (
190169 scope = scope ,
191170 location = Location (
@@ -200,24 +179,77 @@ async def run_dispatcher(
200179 ),
201180 )
202181 ),
203- self .send_json_ws (send ),
182+ self .send_json (send ),
204183 recv_queue .get ,
205184 )
206185 except Exception as error :
207186 await asyncio .to_thread (_logger .error , f"{ error } \n { traceback .format_exc ()} " )
208187
209188 @staticmethod
210- def send_json_ws (send : Callable ) -> Callable [..., Coroutine ]:
189+ def send_json (send : Callable ) -> Callable [..., Coroutine ]:
211190 """Use orjson to send JSON over an ASGI websocket."""
212191
213192 async def _send_json (value : Any ) -> None :
214193 await send ({"type" : "websocket.send" , "text" : orjson .dumps (value ).decode ()})
215194
216195 return _send_json
217196
218- def match_dispatch_path (self , scope : dict ) -> bool :
219- match = re .match (self .dispatcher_pattern , scope ["path" ])
220- return bool (
221- match
222- and match .groupdict ().get ("dotted_path" ) in self .component_dotted_paths
223- )
197+
198+ @dataclass
199+ class StaticFileApp :
200+ parent : ReactPyMiddleware
201+ _static_file_server : ServeStaticASGI | None = None
202+
203+ async def __call__ (
204+ self ,
205+ scope : dict [str , Any ],
206+ receive : Callable [..., Coroutine ],
207+ send : Callable [..., Coroutine ],
208+ ) -> None :
209+ """ASGI app for ReactPy static files."""
210+ # If no static directory is configured, serve the user's application
211+ if not self .parent .static_dir :
212+ await asyncio .to_thread (
213+ _logger .info ,
214+ "Tried to serve static file without a configured directory." ,
215+ )
216+ return await self .parent .user_app (scope , receive , send )
217+
218+ if not self ._static_file_server :
219+ self ._static_file_server = ServeStaticASGI (
220+ self .parent .user_app ,
221+ root = self .parent .static_dir ,
222+ prefix = self .parent .static_path ,
223+ )
224+
225+ return await self ._static_file_server (scope , receive , send )
226+
227+
228+ @dataclass
229+ class WebModuleApp :
230+ parent : ReactPyMiddleware
231+ _static_file_server : ServeStaticASGI | None = None
232+
233+ async def __call__ (
234+ self ,
235+ scope : dict [str , Any ],
236+ receive : Callable [..., Coroutine ],
237+ send : Callable [..., Coroutine ],
238+ ) -> None :
239+ """ASGI app for ReactPy web modules."""
240+ if not self .parent .web_modules_dir :
241+ await asyncio .to_thread (
242+ _logger .info ,
243+ "Tried to serve web module without a configured directory." ,
244+ )
245+ return await self .parent .user_app (scope , receive , send )
246+
247+ if not self ._static_file_server :
248+ self ._static_file_server = ServeStaticASGI (
249+ self .parent .user_app ,
250+ root = self .parent .web_modules_dir ,
251+ prefix = self .parent .web_modules_path ,
252+ autorefresh = True ,
253+ )
254+
255+ return await self ._static_file_server (scope , receive , send )
0 commit comments