From ca023580e46228b7aefcfb4c367fc18c8cbb83a2 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Mon, 20 Jul 2026 00:52:26 +0000 Subject: [PATCH] Fix Daphne ASGI compatibility and document WS handshake cookie limitation - AsgiContext and AsgiWebSocket now default missing scope key to 'http' / 'ws' respectively (Daphne omits it for websocket scopes) - Add regression test for websocket scope without scheme - Update example/session.py WS handler to read the session; cookie must be set via HTTP first because common ASGI servers ignore the headers field on websocket.accept - Update README with the same caveat about Granian/Daphne/curl --- example/session.py | 8 ++++++-- packages/kaya-core/src/kaya/core/_asgi.py | 4 ++-- packages/kaya-core/tests/test_websocket.py | 20 ++++++++++++++++++++ packages/kaya-session/README.md | 19 +++++++++++-------- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/example/session.py b/example/session.py index 58176d9..78dff2b 100644 --- a/example/session.py +++ b/example/session.py @@ -35,7 +35,11 @@ async def echo(ws: WebSocket) -> None: @app.websocket('/ws/visits') async def ws_visits(ws: WebSocket) -> None: - visits = ws.session.get('visits', 0) + 1 - ws.session['visits'] = visits + # WebSocket handlers can read the existing session. Most ASGI servers + # (including Granian and Daphne) do not forward the `headers` field of the + # `websocket.accept` message into the HTTP 101 response, so a new session + # cookie cannot be set during the handshake. Use the HTTP `/` endpoint to + # set or refresh the session cookie before connecting here. await ws.accept() + visits = ws.session.get('visits', 0) await ws.send_text(f'visits: {visits}') diff --git a/packages/kaya-core/src/kaya/core/_asgi.py b/packages/kaya-core/src/kaya/core/_asgi.py index 09ac05a..0612612 100644 --- a/packages/kaya-core/src/kaya/core/_asgi.py +++ b/packages/kaya-core/src/kaya/core/_asgi.py @@ -83,7 +83,7 @@ class AsgiContext(HttpContext): self.path = scope['path'] self.query_string = scope['query_string'].decode() self.method = HttpMethod(scope['method']) - self.scheme = scope['scheme'] + self.scheme = scope.get('scheme', 'http') self.client = scope['client'] self.server = scope['server'] self.headers = decode_headers(scope['headers']) @@ -168,7 +168,7 @@ class AsgiWebSocket(WebSocket): self._send = send self.path = scope['path'] self.query_string = scope['query_string'].decode() - self.scheme = scope['scheme'] + self.scheme = scope.get('scheme', 'ws') self.client = scope['client'] self.server = scope['server'] self.headers = decode_headers(scope['headers']) diff --git a/packages/kaya-core/tests/test_websocket.py b/packages/kaya-core/tests/test_websocket.py index 08536ca..14120f3 100644 --- a/packages/kaya-core/tests/test_websocket.py +++ b/packages/kaya-core/tests/test_websocket.py @@ -131,3 +131,23 @@ class WebSocketTest(unittest.TestCase): await ws.accept() self.assertEqual(1, len(sent_messages)) self.assertEqual({'type': 'websocket.accept'}, sent_messages[0]) + + @async_test + async def test_websocket_scope_without_scheme(self): + # Daphne omits the optional `scheme` key from websocket scopes. + async def send(message): + pass + + async def receive(): + return {'type': 'websocket.connect'} + + scope = { + 'type': 'websocket', + 'path': '/echo', + 'query_string': b'', + 'client': ('127.0.0.1', 12345), + 'server': ('127.0.0.1', 80), + 'headers': [], + } + ws = AsgiWebSocket(scope, receive, send) + self.assertEqual('ws', ws.scheme) diff --git a/packages/kaya-session/README.md b/packages/kaya-session/README.md index bdcf3bc..86b0e5a 100644 --- a/packages/kaya-session/README.md +++ b/packages/kaya-session/README.md @@ -34,19 +34,22 @@ The same session is available in websocket handlers as `ws.session`: ```python @app.websocket('/ws/visits') async def ws_visits(ws: WebSocket): - visits = ws.session.get('visits', 0) + 1 - ws.session['visits'] = visits await ws.accept() + visits = ws.session.get('visits', 0) await ws.send_text(f'visits: {visits}') ``` The session is loaded from the cookie when the connection is opened and -persisted when the connection closes, if it was modified. The session cookie -can only be set or refreshed on the handshake response, so mutate the session -*before* calling `ws.accept()` if you want the cookie delivered with the -handshake. Handshake cookies require ASGI spec version 2.1+; RSGI websocket -handshakes cannot carry response headers, so on RSGI the session is loaded and -persisted but the cookie is only set or refreshed by HTTP responses. +persisted when the connection closes, if it was modified. + +**Important:** although the ASGI spec allows custom headers on the WebSocket +handshake response (`websocket.accept` headers, spec 2.1+), most ASGI servers +in practice — including **Granian** and **Daphne** — do not forward them into +the HTTP `101` response. RSGI websocket handshakes cannot carry response headers +at all. Therefore, in real deployments a session cookie can only be set or +refreshed by an HTTP response. Use an HTTP endpoint to establish or update the +session before opening the WebSocket, and read the existing session in the +WebSocket handler. ## Session expiry