@@ -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
263252async 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
332314async 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