Skip to content

Commit 9336098

Browse files
authored
Advanced Websocket Features (#17)
* propogate websocket down * update tests * add websocket login * add admin view to test app * fix styling error * more styling * last styling error i swear * fix spelling error * remove unneeded sys import check * reconnecting WS (broken) * prevent duplicate component registration fix #5 * allow any host to access this test app * cache -> lru_cache * py 3.7 compartibility * memory efficient way of preventing reregistration * Limit developer control the websocket * fix readme filename (idom.py -> components,py) * format readme * simplify render example * fix settings anchor link * add IDOM_WS_RECONNECT to readme * add session middleware * change disconnect to close * add view ID to WebSocketConnection * change tab width to 2 * prettier format * IDOM_WS_RECONNECT -> IDOM_WS_RECONNECT_TIMEOUT * format base.html * WebSocket -> Websocket * Awaitable[None] * add disconnect within websocketconnection * bump idom version * revert component registration check * use django-idom version for svg * add EOF newlines * cleanup WebsocketConnection * remove useless init from websocket consumer * IDOM_WS_MAX_RECONNECT_DELAY * self.view_id -> view_id
1 parent 71968ee commit 9336098

File tree

15 files changed

+98
-62
lines changed

15 files changed

+98
-62
lines changed

README.md

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<img alt="Tests" src="https://github.com/idom-team/django-idom/workflows/Test/badge.svg?event=push" />
66
</a>
77
<a href="https://pypi.python.org/pypi/django-idom">
8-
<img alt="Version Info" src="https://img.shields.io/pypi/v/idom.svg"/>
8+
<img alt="Version Info" src="https://img.shields.io/pypi/v/django-idom.svg"/>
99
</a>
1010
<a href="https://github.com/idom-team/django-idom/blob/main/LICENSE">
1111
<img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-purple.svg">
@@ -20,14 +20,13 @@ interfaces in pure Python.
2020
<a
2121
target="_blank"
2222
href="https://mybinder.org/v2/gh/idom-team/idom-jupyter/main?filepath=notebooks%2Fintroduction.ipynb">
23-
<img
23+
<img
2424
alt="Binder"
2525
valign="bottom"
2626
height="21px"
2727
src="https://mybinder.org/badge_logo.svg"/>
2828
</a>
2929

30-
3130
# Install Django IDOM
3231

3332
```bash
@@ -46,7 +45,7 @@ your_project/
4645
├── urls.py
4746
└── example_app/
4847
├── __init__.py
49-
├── idom.py
48+
├── components.py
5049
├── templates/
5150
│ └── your-template.html
5251
└── urls.py
@@ -60,7 +59,7 @@ order to create ASGI websockets within Django. Then, we will add a path for IDOM
6059
websocket consumer using `IDOM_WEBSOCKET_PATH`.
6160

6261
_Note: If you wish to change the route where this websocket is served from, see the
63-
available [settings](#settings.py)._
62+
available [settings](#settingspy)._
6463

6564
```python
6665

@@ -75,14 +74,14 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings")
7574
# Fetch ASGI application before importing dependencies that require ORM models.
7675
http_asgi_app = get_asgi_application()
7776

77+
from channels.auth import AuthMiddlewareStack
7878
from channels.routing import ProtocolTypeRouter, URLRouter
7979

8080
application = ProtocolTypeRouter(
8181
{
8282
"http": http_asgi_app,
83-
"websocket": URLRouter(
84-
# add a path for IDOM's websocket
85-
[IDOM_WEBSOCKET_PATH]
83+
"websocket": SessionMiddlewareStack(
84+
AuthMiddlewareStack(URLRouter([IDOM_WEBSOCKET_PATH]))
8685
),
8786
}
8887
)
@@ -111,6 +110,10 @@ IDOM_BASE_URL: str = "_idom/"
111110
# Only applies when not using Django's caching framework (see below).
112111
IDOM_WEB_MODULE_LRU_CACHE_SIZE: int | None = None
113112

113+
# Maximum seconds between two reconnection attempts that would cause the client give up.
114+
# 0 will disable reconnection.
115+
IDOM_WS_MAX_RECONNECT_DELAY: int = 604800
116+
114117
# Configure a cache for loading JS files
115118
CACHES = {
116119
# Configure a cache for loading JS files for IDOM
@@ -147,8 +150,8 @@ ultimately be referenced by name in `your-template.html`. `your-template.html`.
147150
import idom
148151

149152
@idom.component
150-
def Hello(greeting_recipient): # component names are camelcase by convention
151-
return Header(f"Hello {greeting_recipient}!")
153+
def Hello(websocket, greeting_recipient): # component names are camelcase by convention
154+
return idom.html.header(f"Hello {greeting_recipient}!")
152155
```
153156

154157
## `example_app/templates/your-template.html`
@@ -165,8 +168,6 @@ idom_component module_name.ComponentName param_1="something" param_2="something-
165168
In context this will look a bit like the following...
166169

167170
```jinja
168-
<!-- don't forget your load statements -->
169-
{% load static %}
170171
{% load idom %}
171172
172173
<!DOCTYPE html>
@@ -184,15 +185,11 @@ You can then serve `your-template.html` from a view just
184185
[like any other](https://docs.djangoproject.com/en/3.2/intro/tutorial03/#write-views-that-actually-do-something).
185186

186187
```python
187-
from django.http import HttpResponse
188-
from django.template import loader
189-
188+
from django.shortcuts import render
190189

191190
def your_view(request):
192191
context = {}
193-
return HttpResponse(
194-
loader.get_template("your-template.html").render(context, request)
195-
)
192+
return render(request, "your-template.html", context)
196193
```
197194

198195
## `example_app/urls.py`

src/django_idom/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
IDOM_BASE_URL = getattr(settings, "IDOM_BASE_URL", "_idom/")
1111
IDOM_WEBSOCKET_URL = IDOM_BASE_URL + "websocket/"
1212
IDOM_WEB_MODULES_URL = IDOM_BASE_URL + "web_module/"
13+
IDOM_WS_MAX_RECONNECT_DELAY = getattr(settings, "IDOM_WS_MAX_RECONNECT_DELAY", 604800)
1314

1415
_CACHES = getattr(settings, "CACHES", {})
1516
if _CACHES:

src/django_idom/paths.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
from . import views
44
from .config import IDOM_WEB_MODULES_URL, IDOM_WEBSOCKET_URL
5-
from .websocket_consumer import IdomAsyncWebSocketConsumer
5+
from .websocket_consumer import IdomAsyncWebsocketConsumer
66

77

88
IDOM_WEBSOCKET_PATH = path(
9-
IDOM_WEBSOCKET_URL + "<view_id>/", IdomAsyncWebSocketConsumer.as_asgi()
9+
IDOM_WEBSOCKET_URL + "<view_id>/", IdomAsyncWebsocketConsumer.as_asgi()
1010
)
11-
"""A URL resolver for :class:`IdomAsyncWebSocketConsumer`
11+
"""A URL resolver for :class:`IdomAsyncWebsocketConsumer`
1212
1313
While this is relatively uncommon in most Django apps, because the URL of the
1414
websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need

src/django_idom/templates/idom/component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
mountPoint,
88
"{{ idom_websocket_url }}",
99
"{{ idom_web_modules_url }}",
10+
"{{ idom_ws_max_reconnect_delay }}",
1011
"{{ idom_component_id }}",
1112
"{{ idom_component_params }}"
1213
);

src/django_idom/templatetags/idom.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
IDOM_REGISTERED_COMPONENTS,
1111
IDOM_WEB_MODULES_URL,
1212
IDOM_WEBSOCKET_URL,
13+
IDOM_WS_MAX_RECONNECT_DELAY,
1314
)
1415

1516

@@ -27,6 +28,7 @@ def idom_component(_component_id_, **kwargs):
2728
"class": class_,
2829
"idom_websocket_url": IDOM_WEBSOCKET_URL,
2930
"idom_web_modules_url": IDOM_WEB_MODULES_URL,
31+
"idom_ws_max_reconnect_delay": IDOM_WS_MAX_RECONNECT_DELAY,
3032
"idom_mount_uuid": uuid4().hex,
3133
"idom_component_id": _component_id_,
3234
"idom_component_params": urlencode({"kwargs": json_kwargs}),

src/django_idom/websocket_consumer.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
import asyncio
33
import json
44
import logging
5-
from typing import Any
5+
from dataclasses import dataclass
6+
from typing import Any, Awaitable, Callable, Optional
67
from urllib.parse import parse_qsl
78

9+
from channels.auth import login
10+
from channels.db import database_sync_to_async as convert_to_async
811
from channels.generic.websocket import AsyncJsonWebsocketConsumer
912
from idom.core.dispatcher import dispatch_single_view
1013
from idom.core.layout import Layout, LayoutEvent
@@ -15,14 +18,30 @@
1518
_logger = logging.getLogger(__name__)
1619

1720

18-
class IdomAsyncWebSocketConsumer(AsyncJsonWebsocketConsumer):
19-
"""Communicates with the browser to perform actions on-demand."""
21+
@dataclass
22+
class WebsocketConnection:
23+
scope: dict
24+
close: Callable[[Optional[int]], Awaitable[None]]
25+
disconnect: Callable[[int], Awaitable[None]]
26+
view_id: str
27+
2028

21-
def __init__(self, *args: Any, **kwargs: Any) -> None:
22-
super().__init__(*args, **kwargs)
29+
class IdomAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer):
30+
"""Communicates with the browser to perform actions on-demand."""
2331

2432
async def connect(self) -> None:
2533
await super().connect()
34+
35+
user = self.scope.get("user")
36+
if user and user.is_authenticated:
37+
try:
38+
await login(self.scope, user)
39+
await convert_to_async(self.scope["session"].save)()
40+
except Exception:
41+
_logger.exception("IDOM websocket authentication has failed!")
42+
elif user is None:
43+
_logger.warning("IDOM websocket is missing AuthMiddlewareStack!")
44+
2645
self._idom_dispatcher_future = asyncio.ensure_future(self._run_dispatch_loop())
2746

2847
async def disconnect(self, code: int) -> None:
@@ -41,14 +60,17 @@ async def _run_dispatch_loop(self):
4160
try:
4261
component_constructor = IDOM_REGISTERED_COMPONENTS[view_id]
4362
except KeyError:
44-
_logger.warning(f"Uknown IDOM view ID {view_id!r}")
63+
_logger.warning(f"Unknown IDOM view ID {view_id!r}")
4564
return
4665

4766
query_dict = dict(parse_qsl(self.scope["query_string"].decode()))
4867
component_kwargs = json.loads(query_dict.get("kwargs", "{}"))
4968

69+
# Provide developer access to parts of this websocket
70+
socket = WebsocketConnection(self.scope, self.close, self.disconnect, view_id)
71+
5072
try:
51-
component_instance = component_constructor(**component_kwargs)
73+
component_instance = component_constructor(socket, **component_kwargs)
5274
except Exception:
5375
_logger.exception(
5476
f"Failed to construct component {component_constructor} "

src/js/package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"rollup-plugin-replace": "^2.2.0"
1919
},
2020
"dependencies": {
21-
"idom-client-react": "^0.33.0",
21+
"idom-client-react": "^0.33.3",
2222
"react": "^17.0.2",
2323
"react-dom": "^17.0.2"
2424
}

src/js/src/index.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,24 @@ export function mountViewToElement(
1414
mountPoint,
1515
idomWebsocketUrl,
1616
idomWebModulesUrl,
17+
maxReconnectTimeout,
1718
viewId,
1819
queryParams
1920
) {
2021
const fullWebsocketUrl =
2122
WS_ENDPOINT_URL + idomWebsocketUrl + viewId + "/?" + queryParams;
2223

23-
const fullWebModulesUrl = LOCATION.origin + "/" + idomWebModulesUrl
24+
const fullWebModulesUrl = LOCATION.origin + "/" + idomWebModulesUrl;
2425
const loadImportSource = (source, sourceType) => {
2526
return import(
2627
sourceType == "NAME" ? `${fullWebModulesUrl}${source}` : source
2728
);
2829
};
2930

30-
mountLayoutWithWebSocket(mountPoint, fullWebsocketUrl, loadImportSource);
31+
mountLayoutWithWebSocket(
32+
mountPoint,
33+
fullWebsocketUrl,
34+
loadImportSource,
35+
maxReconnectTimeout
36+
);
3137
}

tests/test_app/asgi.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@
1919
# Fetch ASGI application before importing dependencies that require ORM models.
2020
http_asgi_app = get_asgi_application()
2121

22+
from channels.auth import AuthMiddlewareStack # noqa: E402
2223
from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402
24+
from channels.sessions import SessionMiddlewareStack # noqa: E402
2325

2426

2527
application = ProtocolTypeRouter(
2628
{
2729
"http": http_asgi_app,
28-
"websocket": URLRouter([IDOM_WEBSOCKET_PATH]),
30+
"websocket": SessionMiddlewareStack(
31+
AuthMiddlewareStack(URLRouter([IDOM_WEBSOCKET_PATH]))
32+
),
2933
}
3034
)

tests/test_app/components.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33

44
@idom.component
5-
def HelloWorld():
5+
def HelloWorld(websocket):
66
return idom.html.h1({"id": "hello-world"}, "Hello World!")
77

88

99
@idom.component
10-
def Button():
10+
def Button(websocket):
1111
count, set_count = idom.hooks.use_state(0)
1212
return idom.html.div(
1313
idom.html.button(
@@ -22,7 +22,7 @@ def Button():
2222

2323

2424
@idom.component
25-
def ParametrizedComponent(x, y):
25+
def ParametrizedComponent(websocket, x, y):
2626
total = x + y
2727
return idom.html.h1({"id": "parametrized-component", "data-value": total}, total)
2828

@@ -32,5 +32,5 @@ def ParametrizedComponent(x, y):
3232

3333

3434
@idom.component
35-
def SimpleBarChart():
35+
def SimpleBarChart(websocket):
3636
return VictoryBar()

tests/test_app/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
# SECURITY WARNING: don't run with debug turned on in production!
2828
DEBUG = True
29-
ALLOWED_HOSTS = []
29+
ALLOWED_HOSTS = ["*"]
3030

3131
# Application definition
3232
INSTALLED_APPS = [

tests/test_app/templates/base.html

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
11
{% load static %} {% load idom %}
22
<!DOCTYPE html>
33
<html lang="en">
4-
<head>
5-
<meta charset="UTF-8" />
6-
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
7-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
8-
<link
9-
rel="shortcut icon"
10-
type="image/png"
11-
href="{% static 'favicon.ico' %}"
12-
/>
13-
<title>IDOM</title>
14-
</head>
154

16-
<body>
17-
<h1>IDOM Test Page</h1>
18-
<div>{% idom_component "test_app.components.HelloWorld" class="hello-world" %}</div>
19-
<div>{% idom_component "test_app.components.Button" class="button" %}</div>
20-
<div>{% idom_component "test_app.components.ParametrizedComponent" class="parametarized-component" x=123 y=456 %}</div>
21-
<div>{% idom_component "test_app.components.SimpleBarChart" class="simple-bar-chart" %}</div>
22-
</body>
5+
<head>
6+
<meta charset="UTF-8" />
7+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
8+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
9+
<link rel="shortcut icon" type="image/png" href="{% static 'favicon.ico' %}" />
10+
<title>IDOM</title>
11+
</head>
12+
13+
<body>
14+
<h1>IDOM Test Page</h1>
15+
<div>{% idom_component "test_app.components.HelloWorld" class="hello-world" %}</div>
16+
<div>{% idom_component "test_app.components.Button" class="button" %}</div>
17+
<div>{% idom_component "test_app.components.ParametrizedComponent" class="parametarized-component" x=123 y=456 %}
18+
</div>
19+
<div>{% idom_component "test_app.components.SimpleBarChart" class="simple-bar-chart" %}</div>
20+
</body>
21+
2322
</html>

tests/test_app/urls.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@
1717
1. Import the include() function: from django.urls import include, path
1818
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
1919
"""
20+
from django.contrib import admin
2021
from django.urls import path
2122

2223
from django_idom import IDOM_WEB_MODULES_PATH
2324

2425
from .views import base_template
2526

2627

27-
urlpatterns = [path("", base_template), IDOM_WEB_MODULES_PATH]
28+
urlpatterns = [
29+
path("", base_template),
30+
IDOM_WEB_MODULES_PATH,
31+
path("admin/", admin.site.urls),
32+
]

0 commit comments

Comments
 (0)