33import asyncio
44import contextlib
55from logging import getLogger
6- from typing import TYPE_CHECKING
6+ from typing import TYPE_CHECKING , Any
77from uuid import uuid4
88
99from django .urls import reverse
1010from reactpy import component , hooks , html
1111
1212from reactpy_django .javascript_components import HttpRequest
13- from reactpy_django .models import SwitchSession
13+ from reactpy_django .models import SynchronizeSession
1414
1515if TYPE_CHECKING :
1616 from django .contrib .sessions .backends .base import SessionBase
1919
2020
2121@component
22- def session_manager (child ):
22+ def session_manager (child : Any ):
2323 """This component can force the client (browser) to switch HTTP sessions,
2424 making it match the websocket session.
2525
2626 Used to force persistent authentication between Django's websocket and HTTP stack."""
2727 from reactpy_django import config
2828
29- switch_sessions , set_switch_sessions = hooks .use_state (False )
29+ synchronize_requested , set_synchronize_requested = hooks .use_state (False )
3030 _ , set_rerender = hooks .use_state (uuid4 )
3131 uuid_ref = hooks .use_ref (str (uuid4 ()))
3232 uuid = uuid_ref .current
3333 scope = hooks .use_connection ().scope
3434
3535 @hooks .use_effect (dependencies = [])
3636 def setup_asgi_scope ():
37- """Store a trigger function in websocket scope so that ReactPy-Django's hooks can command a session synchronization."""
37+ """Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command
38+ any relevant actions."""
3839 scope .setdefault ("reactpy" , {})
3940 scope ["reactpy" ]["synchronize_session" ] = synchronize_session
4041 scope ["reactpy" ]["rerender" ] = rerender
4142
42- @hooks .use_effect (dependencies = [switch_sessions ])
43- async def synchronize_session_timeout ():
44- """Ensure that the ASGI scope is available to this component.
45- This effect will automatically be cancelled if the session is successfully
46- switched (via dependencies=[switch_sessions])."""
47- if switch_sessions :
43+ @hooks .use_effect (dependencies = [synchronize_requested ])
44+ async def synchronize_session_watchdog ():
45+ """This effect will automatically be cancelled if the session is successfully
46+ switched (via effect dependencies)."""
47+ if synchronize_requested :
4848 await asyncio .sleep (config .REACTPY_AUTH_TIMEOUT + 0.1 )
4949 await asyncio .to_thread (
5050 _logger .warning ,
5151 f"Client did not switch sessions within { config .REACTPY_AUTH_TIMEOUT } (REACTPY_AUTH_TIMEOUT) seconds." ,
5252 )
53- set_switch_sessions (False )
53+ set_synchronize_requested (False )
5454
5555 async def synchronize_session ():
5656 """Entrypoint where the server will command the client to switch HTTP sessions
@@ -60,37 +60,43 @@ async def synchronize_session():
6060 if not session or not session .session_key :
6161 return
6262
63- # Delete any sessions currently associated with this UUID
64- with contextlib .suppress (SwitchSession .DoesNotExist ):
65- obj = await SwitchSession .objects .aget (uuid = uuid )
63+ # Delete any sessions currently associated with this UUID, which also resets
64+ # the SynchronizeSession validity time.
65+ # This exists to fix scenarios where...
66+ # 1) The developer manually rotates the session key.
67+ # 2) A component tree requests multiple logins back-to-back before they finish.
68+ # 3) A login is requested, but the server failed to respond to the HTTP request.
69+ with contextlib .suppress (SynchronizeSession .DoesNotExist ):
70+ obj = await SynchronizeSession .objects .aget (uuid = uuid )
6671 await obj .adelete ()
6772
6873 # Begin the process of synchronizing HTTP and websocket sessions
69- obj = await SwitchSession .objects .acreate (uuid = uuid , session_key = session .session_key )
74+ obj = await SynchronizeSession .objects .acreate (uuid = uuid , session_key = session .session_key )
7075 await obj .asave ()
71- set_switch_sessions (True )
76+ set_synchronize_requested (True )
7277
7378 async def synchronize_session_callback (status_code : int , response : str ):
7479 """This callback acts as a communication bridge, allowing the client to notify the server
75- of the status of session switch command ."""
76- set_switch_sessions (False )
80+ of the status of session switch."""
81+ set_synchronize_requested (False )
7782 if status_code >= 300 or status_code < 200 :
7883 await asyncio .to_thread (
7984 _logger .warning ,
8085 f"Client returned unexpected HTTP status code ({ status_code } ) while trying to sychronize sessions." ,
8186 )
8287
8388 async def rerender ():
84- """Force a rerender of the entire component tree."""
89+ """Event that can force a rerender of the entire component tree."""
8590 set_rerender (uuid4 ())
8691
87- # Switch sessions using a client side HttpRequest component, if needed
92+ # If needed, synchronize sessions by configuring all relevant session cookies.
93+ # This is achieved by commanding the client to perform a HTTP request to our session manager endpoint.
8894 http_request = None
89- if switch_sessions :
95+ if synchronize_requested :
9096 http_request = HttpRequest (
9197 {
9298 "method" : "GET" ,
93- "url" : reverse ("reactpy:switch_session " , args = [uuid ]),
99+ "url" : reverse ("reactpy:session_manager " , args = [uuid ]),
94100 "body" : None ,
95101 "callback" : synchronize_session_callback ,
96102 },
0 commit comments