Skip to content

Commit 6d4c456

Browse files
committed
idea on how sessions might be implemented
1 parent a2bd7ad commit 6d4c456

File tree

3 files changed

+115
-15
lines changed

3 files changed

+115
-15
lines changed

src/idom/backend/sanic.py

+52-11
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import asyncio
44
import json
55
import logging
6-
from dataclasses import dataclass
6+
from dataclasses import dataclass, replace
77
from typing import Any, Dict, Tuple, Union
88
from urllib import parse as urllib_parse
99
from uuid import uuid4
@@ -14,7 +14,7 @@
1414
from sanic_cors import CORS
1515
from websockets.legacy.protocol import WebSocketCommonProtocol
1616

17-
from idom.backend.types import Location
17+
from idom.backend.types import Location, SessionState
1818
from idom.core.hooks import Context, create_context, use_context
1919
from idom.core.layout import Layout, LayoutEvent
2020
from idom.core.serve import (
@@ -27,7 +27,12 @@
2727
from idom.core.types import RootComponentConstructor
2828

2929
from ._asgi import serve_development_asgi
30-
from .utils import safe_client_build_dir_path, safe_web_modules_dir_path
30+
from .utils import (
31+
SESSION_COOKIE_NAME,
32+
SessionManager,
33+
safe_client_build_dir_path,
34+
safe_web_modules_dir_path,
35+
)
3136

3237

3338
logger = logging.getLogger(__name__)
@@ -47,7 +52,7 @@ def configure(
4752
_setup_common_routes(blueprint, options)
4853

4954
# this route should take priority so set up it up first
50-
_setup_single_view_dispatcher_route(blueprint, component)
55+
_setup_single_view_dispatcher_route(blueprint, component, options)
5156

5257
app.blueprint(blueprint)
5358

@@ -129,6 +134,9 @@ class Options:
129134
url_prefix: str = ""
130135
"""The URL prefix where IDOM resources will be served from"""
131136

137+
session_manager: SessionManager[Any] | None = None
138+
"""Used to create session cookies to perserve client state"""
139+
132140

133141
def _setup_common_routes(blueprint: Blueprint, options: Options) -> None:
134142
cors_options = options.cors
@@ -164,22 +172,55 @@ async def web_module_files(
164172

165173

166174
def _setup_single_view_dispatcher_route(
167-
blueprint: Blueprint, constructor: RootComponentConstructor
175+
blueprint: Blueprint,
176+
constructor: RootComponentConstructor,
177+
options: Options,
168178
) -> None:
169179
async def model_stream(
170180
request: request.Request, socket: WebSocketCommonProtocol, path: str = ""
171181
) -> None:
182+
root = ConnectionContext(constructor(), value=Connection(request, socket, path))
183+
184+
if options.session_manager:
185+
root = options.session_manager.context(
186+
root, value=request.ctx.idom_sesssion_state
187+
)
188+
172189
send, recv = _make_send_recv_callbacks(socket)
173-
conn = Connection(request, socket, path)
174-
await serve_json_patch(
175-
Layout(ConnectionContext(constructor(), value=conn)),
176-
send,
177-
recv,
178-
)
190+
await serve_json_patch(Layout(root), send, recv)
179191

180192
blueprint.add_websocket_route(model_stream, "/_api/stream")
181193
blueprint.add_websocket_route(model_stream, "/<path:path>/_api/stream")
182194

195+
if options.session_manager:
196+
smgr = options.session_manager
197+
198+
@blueprint.on_request
199+
async def set_session_on_request(request: request.Request) -> None:
200+
if request.scheme not in ("http", "https"):
201+
return
202+
session_id = request.cookies.get(SESSION_COOKIE_NAME)
203+
request.ctx.idom_session_state = await smgr.get_state(session_id)
204+
205+
@blueprint.on_response
206+
async def set_session_cookie_header(
207+
request: request.Request, response: response.ResponseStream
208+
):
209+
session_state: SessionState[Any] = request.ctx.idom_session_state
210+
# only set cookie if it has not been set before
211+
if session_state.fresh:
212+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
213+
response.cookies[SESSION_COOKIE_NAME] = session_state.id
214+
response.cookies[SESSION_COOKIE_NAME]["secure"] = True
215+
response.cookies[SESSION_COOKIE_NAME]["httponly"] = True
216+
response.cookies[SESSION_COOKIE_NAME]["samesite"] = "strict"
217+
response.cookies[SESSION_COOKIE_NAME][
218+
"expires"
219+
] = session_state.expiry_date
220+
221+
await smgr.update_state(replace(session_state, fresh=False))
222+
logger.info(f"Setting cookie {response.cookies[SESSION_COOKIE_NAME]}")
223+
183224

184225
def _make_send_recv_callbacks(
185226
socket: WebSocketCommonProtocol,

src/idom/backend/types.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

33
import asyncio
4-
from dataclasses import dataclass
5-
from typing import Any, MutableMapping, TypeVar
4+
from dataclasses import dataclass, field
5+
from datetime import datetime
6+
from typing import Any, Generic, MutableMapping, TypeVar
67

78
from typing_extensions import Protocol, runtime_checkable
89

@@ -56,3 +57,14 @@ class Location:
5657

5758
search: str = ""
5859
"""A search or query string - a '?' followed by the parameters of the URL."""
60+
61+
62+
_State = TypeVar("_State")
63+
64+
65+
@dataclass
66+
class SessionState(Generic[_State]):
67+
id: str
68+
value: _State
69+
expiry_date: datetime
70+
fresh: bool = field(default_factory=lambda: True)

src/idom/backend/utils.py

+49-2
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@
55
import os
66
import socket
77
from contextlib import closing
8+
from dataclasses import dataclass
9+
from datetime import datetime, timezone
810
from importlib import import_module
911
from pathlib import Path
10-
from typing import Any, Iterator
12+
from typing import Any, Awaitable, Callable, Generic, Iterator, TypeVar
1113

1214
import idom
1315
from idom.config import IDOM_WEB_MODULES_DIR
16+
from idom.core.hooks import create_context, use_context
1417
from idom.types import RootComponentConstructor
1518

16-
from .types import BackendImplementation
19+
from .types import BackendImplementation, SessionState
1720

1821

1922
logger = logging.getLogger(__name__)
@@ -28,6 +31,50 @@
2831
)
2932

3033

34+
SESSION_COOKIE_NAME = "IdomSessionId"
35+
36+
37+
_State = TypeVar("_State")
38+
39+
40+
class SessionManager(Generic[_State]):
41+
def __init__(
42+
self,
43+
create_state: Callable[[], Awaitable[_State]],
44+
read_state: Callable[[str], Awaitable[_State | None]],
45+
update_state: Callable[[_State], Awaitable[None]],
46+
):
47+
self.context = create_context(None)
48+
self._create_state = create_state
49+
self._read_state = read_state
50+
self._update_state = update_state
51+
52+
def use_context(self) -> _State:
53+
return use_context(self.context)
54+
55+
async def update_state(self, state: SessionState[_State]) -> None:
56+
await self._update_state(state)
57+
logger.info(f"Updated session {state.id}")
58+
59+
async def get_state(self, id: str | None = None) -> SessionState[_State] | None:
60+
if id is None:
61+
state = await self._create_state()
62+
logger.info(f"Created new session {state.id!r}")
63+
else:
64+
state = await self._read_state(id)
65+
if state is None:
66+
logger.info(f"Could not load session {id!r}")
67+
state = await self._create_state()
68+
logger.info(f"Created new session {state.id!r}")
69+
elif state.expiry_date < datetime.now(timezone.utc):
70+
logger.info(f"Session {id!r} expired at {state.expiry_date}")
71+
state = await self._create_state()
72+
logger.info(f"Created new session {state.id!r}")
73+
else:
74+
logger.info(f"Loaded existing session {id!r}")
75+
return state
76+
77+
3178
def run(
3279
component: RootComponentConstructor,
3380
host: str = "127.0.0.1",

0 commit comments

Comments
 (0)