Skip to content

Advanced Websocket Features #17

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 39 commits into from
Nov 1, 2021
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
fc8afa6
propogate websocket down
Archmonger Sep 21, 2021
4ec52f5
update tests
Archmonger Sep 21, 2021
839e8d7
add websocket login
Archmonger Sep 21, 2021
c1b443e
add admin view to test app
Archmonger Sep 21, 2021
79ab0bc
fix styling error
Archmonger Sep 21, 2021
bf6783a
more styling
Archmonger Sep 21, 2021
dda8b58
last styling error i swear
Archmonger Sep 21, 2021
05a3d22
fix spelling error
Archmonger Sep 22, 2021
ef9895b
remove unneeded sys import check
Archmonger Sep 22, 2021
a4d439c
reconnecting WS (broken)
Archmonger Sep 22, 2021
f17ba96
prevent duplicate component registration fix #5
Archmonger Sep 22, 2021
aa10d8c
allow any host to access this test app
Archmonger Sep 22, 2021
756b80b
cache -> lru_cache
Archmonger Sep 22, 2021
2786a3b
py 3.7 compartibility
Archmonger Sep 22, 2021
2c1adb9
memory efficient way of preventing reregistration
Archmonger Sep 22, 2021
e50ca05
Limit developer control the websocket
Archmonger Sep 22, 2021
bbb4649
fix readme filename (idom.py -> components,py)
Archmonger Sep 22, 2021
88dfe65
format readme
Archmonger Sep 22, 2021
a4b8f56
simplify render example
Archmonger Sep 22, 2021
4bcbfdd
fix settings anchor link
Archmonger Sep 22, 2021
cfc07cd
add IDOM_WS_RECONNECT to readme
Archmonger Sep 22, 2021
3afbed5
add session middleware
Archmonger Sep 22, 2021
9e4b13e
change disconnect to close
Archmonger Sep 27, 2021
d625ee0
add view ID to WebSocketConnection
Archmonger Sep 27, 2021
b36ac37
change tab width to 2
Archmonger Sep 27, 2021
7336b50
prettier format
Archmonger Sep 27, 2021
7648256
IDOM_WS_RECONNECT -> IDOM_WS_RECONNECT_TIMEOUT
Archmonger Sep 27, 2021
2387693
format base.html
Archmonger Sep 27, 2021
553da97
WebSocket -> Websocket
Archmonger Sep 27, 2021
9058d34
Awaitable[None]
Archmonger Sep 27, 2021
4efc14f
add disconnect within websocketconnection
Archmonger Oct 6, 2021
0e2436c
bump idom version
Archmonger Oct 9, 2021
43a57fb
revert component registration check
Archmonger Oct 30, 2021
2885e85
use django-idom version for svg
Archmonger Nov 1, 2021
47d8ec6
add EOF newlines
Archmonger Nov 1, 2021
dbcf2b4
cleanup WebsocketConnection
Archmonger Nov 1, 2021
991513e
remove useless init from websocket consumer
Archmonger Nov 1, 2021
568064f
IDOM_WS_MAX_RECONNECT_DELAY
Archmonger Nov 1, 2021
54b5186
self.view_id -> view_id
Archmonger Nov 1, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 13 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,13 @@ interfaces in pure Python.
<a
target="_blank"
href="https://mybinder.org/v2/gh/idom-team/idom-jupyter/main?filepath=notebooks%2Fintroduction.ipynb">
<img
<img
alt="Binder"
valign="bottom"
height="21px"
src="https://mybinder.org/badge_logo.svg"/>
</a>


# Install Django IDOM

```bash
Expand All @@ -46,7 +45,7 @@ your_project/
├── urls.py
└── example_app/
├── __init__.py
├── idom.py
├── components.py
├── templates/
│ └── your-template.html
└── urls.py
Expand All @@ -60,7 +59,7 @@ order to create ASGI websockets within Django. Then, we will add a path for IDOM
websocket consumer using `IDOM_WEBSOCKET_PATH`.

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

```python

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

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter

application = ProtocolTypeRouter(
{
"http": http_asgi_app,
"websocket": URLRouter(
# add a path for IDOM's websocket
[IDOM_WEBSOCKET_PATH]
"websocket": SessionMiddlewareStack(
AuthMiddlewareStack(URLRouter([IDOM_WEBSOCKET_PATH]))
),
}
)
Expand Down Expand Up @@ -111,6 +110,9 @@ IDOM_BASE_URL: str = "_idom/"
# Only applies when not using Django's caching framework (see below).
IDOM_WEB_MODULE_LRU_CACHE_SIZE: int | None = None

# Max amount of time for client to attempt to reconnect. 0 disables reconnection.
IDOM_WS_RECONNECT_TIMEOUT: int = 604800

# Configure a cache for loading JS files
CACHES = {
# Configure a cache for loading JS files for IDOM
Expand Down Expand Up @@ -147,8 +149,8 @@ ultimately be referenced by name in `your-template.html`. `your-template.html`.
import idom

@idom.component
def Hello(greeting_recipient): # component names are camelcase by convention
return Header(f"Hello {greeting_recipient}!")
def Hello(websocket, greeting_recipient): # component names are camelcase by convention
return idom.html.header(f"Hello {greeting_recipient}!")
```

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

```jinja
<!-- don't forget your load statements -->
{% load static %}
{% load idom %}

<!DOCTYPE html>
Expand All @@ -184,15 +184,11 @@ You can then serve `your-template.html` from a view just
[like any other](https://docs.djangoproject.com/en/3.2/intro/tutorial03/#write-views-that-actually-do-something).

```python
from django.http import HttpResponse
from django.template import loader

from django.shortcuts import render

def your_view(request):
context = {}
return HttpResponse(
loader.get_template("your-template.html").render(context, request)
)
return render(request, "your-template.html", context)
```

## `example_app/urls.py`
Expand Down
1 change: 1 addition & 0 deletions src/django_idom/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
IDOM_BASE_URL = getattr(settings, "IDOM_BASE_URL", "_idom/")
IDOM_WEBSOCKET_URL = IDOM_BASE_URL + "websocket/"
IDOM_WEB_MODULES_URL = IDOM_BASE_URL + "web_module/"
IDOM_WS_RECONNECT_TIMEOUT = getattr(settings, "IDOM_WS_RECONNECT_TIMEOUT", 604800)

_CACHES = getattr(settings, "CACHES", {})
if _CACHES:
Expand Down
6 changes: 3 additions & 3 deletions src/django_idom/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

from . import views
from .config import IDOM_WEB_MODULES_URL, IDOM_WEBSOCKET_URL
from .websocket_consumer import IdomAsyncWebSocketConsumer
from .websocket_consumer import IdomAsyncWebsocketConsumer


IDOM_WEBSOCKET_PATH = path(
IDOM_WEBSOCKET_URL + "<view_id>/", IdomAsyncWebSocketConsumer.as_asgi()
IDOM_WEBSOCKET_URL + "<view_id>/", IdomAsyncWebsocketConsumer.as_asgi()
)
"""A URL resolver for :class:`IdomAsyncWebSocketConsumer`
"""A URL resolver for :class:`IdomAsyncWebsocketConsumer`

While this is relatively uncommon in most Django apps, because the URL of the
websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need
Expand Down
3 changes: 2 additions & 1 deletion src/django_idom/templates/idom/component.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
mountPoint,
"{{ idom_websocket_url }}",
"{{ idom_web_modules_url }}",
"{{ idom_ws_reconnect_timeout }}",
"{{ idom_component_id }}",
"{{ idom_component_params }}"
);
</script>
</script>
21 changes: 11 additions & 10 deletions src/django_idom/templatetags/idom.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import json
import sys
from importlib import import_module
from urllib.parse import urlencode
from uuid import uuid4
Expand All @@ -10,6 +9,7 @@
IDOM_REGISTERED_COMPONENTS,
IDOM_WEB_MODULES_URL,
IDOM_WEBSOCKET_URL,
IDOM_WS_RECONNECT_TIMEOUT,
)


Expand All @@ -27,24 +27,25 @@ def idom_component(_component_id_, **kwargs):
"class": class_,
"idom_websocket_url": IDOM_WEBSOCKET_URL,
"idom_web_modules_url": IDOM_WEB_MODULES_URL,
"idom_ws_reconnect_timeout": IDOM_WS_RECONNECT_TIMEOUT,
"idom_mount_uuid": uuid4().hex,
"idom_component_id": _component_id_,
"idom_component_params": urlencode({"kwargs": json_kwargs}),
}


def _register_component(full_component_name: str) -> None:
if full_component_name in IDOM_REGISTERED_COMPONENTS:
return

module_name, component_name = full_component_name.rsplit(".", 1)

if module_name in sys.modules:
module = sys.modules[module_name]
else:
try:
module = import_module(module_name)
except ImportError as error:
raise RuntimeError(
f"Failed to import {module_name!r} while loading {component_name!r}"
) from error
try:
module = import_module(module_name)
except ImportError as error:
raise RuntimeError(
f"Failed to import {module_name!r} while loading {component_name!r}"
) from error

try:
component = getattr(module, component_name)
Expand Down
37 changes: 31 additions & 6 deletions src/django_idom/websocket_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
import asyncio
import json
import logging
from typing import Any
from dataclasses import dataclass
from typing import Any, Callable, Coroutine, Optional
from urllib.parse import parse_qsl

from channels.auth import login
from channels.db import database_sync_to_async as convert_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from idom.core.dispatcher import dispatch_single_view
from idom.core.layout import Layout, LayoutEvent
Expand All @@ -15,14 +18,37 @@
_logger = logging.getLogger(__name__)


class IdomAsyncWebSocketConsumer(AsyncJsonWebsocketConsumer):
@dataclass
class WebsocketConnection:
scope: dict
close: Callable[[Optional[int]], Coroutine[Any, Any, None]]
view_id: str


class IdomAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer):
"""Communicates with the browser to perform actions on-demand."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)

async def connect(self) -> None:
await super().connect()

self.view_id = self.scope["url_route"]["kwargs"]["view_id"]

user = self.scope.get("user")
if user and user.is_authenticated:
try:
await login(self.scope, user)
await convert_to_async(self.scope["session"].save)()
except Exception:
_logger.exception("IDOM websocket authentication has failed!")
elif user is None:
_logger.warning("IDOM websocket is missing AuthMiddlewareStack!")

# Limit developer control this websocket
self.socket = WebsocketConnection(self.scope, self.close, self.view_id)

self._idom_dispatcher_future = asyncio.ensure_future(self._run_dispatch_loop())

async def disconnect(self, code: int) -> None:
Expand All @@ -36,19 +62,18 @@ async def receive_json(self, content: Any, **kwargs: Any) -> None:
await self._idom_recv_queue.put(LayoutEvent(**content))

async def _run_dispatch_loop(self):
view_id = self.scope["url_route"]["kwargs"]["view_id"]

try:
component_constructor = IDOM_REGISTERED_COMPONENTS[view_id]
component_constructor = IDOM_REGISTERED_COMPONENTS[self.view_id]
except KeyError:
_logger.warning(f"Uknown IDOM view ID {view_id!r}")
_logger.warning(f"Unknown IDOM view ID {self.view_id!r}")
return

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

try:
component_instance = component_constructor(**component_kwargs)
component_instance = component_constructor(self.socket, **component_kwargs)
except Exception:
_logger.exception(
f"Failed to construct component {component_constructor} "
Expand Down
10 changes: 8 additions & 2 deletions src/js/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,24 @@ export function mountViewToElement(
mountPoint,
idomWebsocketUrl,
idomWebModulesUrl,
maxReconnectTimeout,
viewId,
queryParams
) {
const fullWebsocketUrl =
WS_ENDPOINT_URL + idomWebsocketUrl + viewId + "/?" + queryParams;

const fullWebModulesUrl = LOCATION.origin + "/" + idomWebModulesUrl
const fullWebModulesUrl = LOCATION.origin + "/" + idomWebModulesUrl;
const loadImportSource = (source, sourceType) => {
return import(
sourceType == "NAME" ? `${fullWebModulesUrl}${source}` : source
);
};

mountLayoutWithWebSocket(mountPoint, fullWebsocketUrl, loadImportSource);
mountLayoutWithWebSocket(
mountPoint,
fullWebsocketUrl,
loadImportSource,
maxReconnectTimeout
);
}
6 changes: 5 additions & 1 deletion tests/test_app/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@
# Fetch ASGI application before importing dependencies that require ORM models.
http_asgi_app = get_asgi_application()

from channels.auth import AuthMiddlewareStack # noqa: E402
from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402
from channels.sessions import SessionMiddlewareStack # noqa: E402


application = ProtocolTypeRouter(
{
"http": http_asgi_app,
"websocket": URLRouter([IDOM_WEBSOCKET_PATH]),
"websocket": SessionMiddlewareStack(
AuthMiddlewareStack(URLRouter([IDOM_WEBSOCKET_PATH]))
),
}
)
8 changes: 4 additions & 4 deletions tests/test_app/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@


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


@idom.component
def Button():
def Button(websocket):
count, set_count = idom.hooks.use_state(0)
return idom.html.div(
idom.html.button(
Expand All @@ -22,7 +22,7 @@ def Button():


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

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


@idom.component
def SimpleBarChart():
def SimpleBarChart(websocket):
return VictoryBar()
2 changes: 1 addition & 1 deletion tests/test_app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
ALLOWED_HOSTS = ["*"]

# Application definition
INSTALLED_APPS = [
Expand Down
37 changes: 18 additions & 19 deletions tests/test_app/templates/base.html
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
{% load static %} {% load idom %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="shortcut icon"
type="image/png"
href="{% static 'favicon.ico' %}"
/>
<title>IDOM</title>
</head>

<body>
<h1>IDOM Test Page</h1>
<div>{% idom_component "test_app.components.HelloWorld" class="hello-world" %}</div>
<div>{% idom_component "test_app.components.Button" class="button" %}</div>
<div>{% idom_component "test_app.components.ParametrizedComponent" class="parametarized-component" x=123 y=456 %}</div>
<div>{% idom_component "test_app.components.SimpleBarChart" class="simple-bar-chart" %}</div>
</body>
</html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" type="image/png" href="{% static 'favicon.ico' %}" />
<title>IDOM</title>
</head>

<body>
<h1>IDOM Test Page</h1>
<div>{% idom_component "test_app.components.HelloWorld" class="hello-world" %}</div>
<div>{% idom_component "test_app.components.Button" class="button" %}</div>
<div>{% idom_component "test_app.components.ParametrizedComponent" class="parametarized-component" x=123 y=456 %}
</div>
<div>{% idom_component "test_app.components.SimpleBarChart" class="simple-bar-chart" %}</div>
</body>

</html>
Loading