diff --git a/.gitignore b/.gitignore
index a59a51e4..07d4c0cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -89,8 +89,8 @@ celerybeat-schedule.*
*.sage.py
# Environments
-.env
-.venv
+.env*/
+.venv*/
env/
venv/
ENV/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8f06d0a..70c5b054 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,7 +34,29 @@ Using the following categories, list your changes in this order:
## [Unreleased]
-- Nothing (yet)!
+### Added
+
+- **Distributed Computing:** ReactPy components can now optionally be rendered by a completely separate server!
+ - `REACTPY_DEFAULT_HOSTS` setting can round-robin a list of ReactPy rendering hosts.
+ - `host` argument has been added to the `component` template tag to force components to render on a specific host.
+- `reactpy_django.utils.register_component` function to manually register root components.
+ - Useful if you have dedicated ReactPy rendering application(s) that do not use HTML templates.
+
+### Changed
+
+- ReactPy will now provide a warning if your HTTP URLs are not on the same prefix as your websockets.
+- Cleaner logging output for auto-detected ReactPy root components.
+
+### Deprecated
+
+- `reactpy_django.REACTPY_WEBSOCKET_PATH` is deprecated. The identical replacement is `REACTPY_WEBSOCKET_ROUTE`.
+- `settings.py:REACTPY_WEBSOCKET_URL` is deprecated. The similar replacement is `REACTPY_URL_PREFIX`.
+
+### Removed
+
+- Warning W007 (`REACTPY_WEBSOCKET_URL doesn't end with a slash`) has been removed. ReactPy now automatically handles slashes.
+- Warning W008 (`REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character`) has been removed. ReactPy now automatically handles this scenario.
+- Error E009 (`channels is not in settings.py:INSTALLED_APPS`) has been removed. Newer versions of `channels` do not require installation via `INSTALLED_APPS` to receive an ASGI webserver.
## [3.3.2] - 2023-08-13
diff --git a/docs/python/configure-asgi-middleware.py b/docs/python/configure-asgi-middleware.py
index 3e8e6523..e817ee48 100644
--- a/docs/python/configure-asgi-middleware.py
+++ b/docs/python/configure-asgi-middleware.py
@@ -1,6 +1,6 @@
# Broken load order, only used for linting
from channels.routing import ProtocolTypeRouter, URLRouter
-from reactpy_django import REACTPY_WEBSOCKET_PATH
+from reactpy_django import REACTPY_WEBSOCKET_ROUTE
django_asgi_app = ""
@@ -15,7 +15,7 @@
"websocket": SessionMiddlewareStack(
AuthMiddlewareStack(
URLRouter(
- [REACTPY_WEBSOCKET_PATH],
+ [REACTPY_WEBSOCKET_ROUTE],
)
)
),
diff --git a/docs/python/configure-asgi.py b/docs/python/configure-asgi.py
index b574c684..8081d747 100644
--- a/docs/python/configure-asgi.py
+++ b/docs/python/configure-asgi.py
@@ -10,11 +10,11 @@
from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402
-from reactpy_django import REACTPY_WEBSOCKET_PATH # noqa: E402
+from reactpy_django import REACTPY_WEBSOCKET_ROUTE # noqa: E402
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
- "websocket": URLRouter([REACTPY_WEBSOCKET_PATH]),
+ "websocket": URLRouter([REACTPY_WEBSOCKET_ROUTE]),
}
)
diff --git a/docs/python/register-component.py b/docs/python/register-component.py
new file mode 100644
index 00000000..c8ad12e9
--- /dev/null
+++ b/docs/python/register-component.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+from reactpy_django.utils import register_component
+
+
+class ExampleConfig(AppConfig):
+ def ready(self):
+ # Add components to the ReactPy component registry when Django is ready
+ register_component("example_project.my_app.components.hello_world")
diff --git a/docs/python/settings.py b/docs/python/settings.py
deleted file mode 100644
index c9a26f5a..00000000
--- a/docs/python/settings.py
+++ /dev/null
@@ -1,32 +0,0 @@
-# Cache used to store ReactPy web modules.
-# ReactPy benefits from a fast, well indexed cache.
-# We recommend redis or python-diskcache.
-REACTPY_CACHE = "default"
-
-# Database ReactPy uses to store session data.
-# ReactPy requires a multiprocessing-safe and thread-safe database.
-# DATABASE_ROUTERS is mandatory if REACTPY_DATABASE is configured.
-REACTPY_DATABASE = "default"
-DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]
-
-# Maximum seconds between reconnection attempts before giving up.
-# Use `0` to prevent component reconnection.
-REACTPY_RECONNECT_MAX = 259200
-
-# The URL for ReactPy to serve the component rendering websocket.
-REACTPY_WEBSOCKET_URL = "reactpy/"
-
-# Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function,
-# or `None`.
-REACTPY_DEFAULT_QUERY_POSTPROCESSOR = "reactpy_django.utils.django_query_postprocessor"
-
-# Dotted path to the Django authentication backend to use for ReactPy components.
-# This is only needed if:
-# 1. You are using `AuthMiddlewareStack` and...
-# 2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
-# 3. Your Django user model does not define a `backend` attribute
-REACTPY_AUTH_BACKEND = "django.contrib.auth.backends.ModelBackend"
-
-# Whether to enable rendering ReactPy via a dedicated backhaul thread.
-# This allows the webserver to process traffic while during ReactPy rendering.
-REACTPY_BACKHAUL_THREAD = False
diff --git a/docs/src/features/components.md b/docs/src/features/components.md
index d7926803..900b9fe2 100644
--- a/docs/src/features/components.md
+++ b/docs/src/features/components.md
@@ -37,7 +37,7 @@ Convert any Django view into a ReactPy component by using this decorator. Compat
It is your responsibility to ensure privileged information is not leaked via this method.
- This can be done via directly writing conditionals into your view, or by adding decorators such as [`user_passes_test`](https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test) to your views prior to using `view_to_component`.
+ You must implement a method to ensure only authorized users can access your view. This can be done via directly writing conditionals into your view, or by adding decorators such as [`user_passes_test`](https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test) to your views. For example...
=== "Function Based View"
diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md
index 3abd6575..29ca81ad 100644
--- a/docs/src/features/settings.md
+++ b/docs/src/features/settings.md
@@ -6,16 +6,25 @@
## Primary Configuration
+
+
These are ReactPy-Django's default settings values. You can modify these values in your **Django project's** `settings.py` to change the behavior of ReactPy.
-=== "settings.py"
+| Setting | Default Value | Example Value(s) | Description |
+| --- | --- | --- | --- |
+| `REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used to store ReactPy web modules. ReactPy benefits from a fast, well indexed cache.
We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). |
+| `REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database ReactPy uses to store session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to also configure `DATABASE_ROUTERS` like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` |
+| `REACTPY_RECONNECT_MAX` | `#!python 259200` | `#!python 96000`, `#!python 60`, `#!python 0` | Maximum seconds between reconnection attempts before giving up.
Use `#!python 0` to prevent reconnection. |
+| `REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix to be used for all ReactPy websocket and HTTP URLs. |
+| `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function. |
+| `REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
1. You are using `AuthMiddlewareStack` and...
2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
3. Your Django user model does not define a `backend` attribute. |
+| `REACTPY_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Whether to render ReactPy components in a dedicated thread. This allows the webserver to process web traffic while during ReactPy rendering.
Vastly improves throughput with web servers such as `hypercorn` and `uvicorn`. |
+| `REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | Default host(s) to use for ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing.
You can use the `host` argument in your [template tag](../features/template-tag.md#component) to override this default. |
- ```python
- {% include "../../python/settings.py" %}
- ```
+
??? question "Do I need to modify my settings?"
- The default configuration of ReactPy is adequate for the majority of use cases.
+ The default configuration of ReactPy is suitable for the majority of use cases.
You should only consider changing settings when the necessity arises.
diff --git a/docs/src/features/template-tag.md b/docs/src/features/template-tag.md
index 9d4ca18c..f1424059 100644
--- a/docs/src/features/template-tag.md
+++ b/docs/src/features/template-tag.md
@@ -20,6 +20,7 @@ The `component` template tag can be used to insert any number of ReactPy compone
| --- | --- | --- | --- |
| `dotted_path` | `str` | The dotted path to the component to render. | N/A |
| `*args` | `Any` | The positional arguments to provide to the component. | N/A |
+ | `host` | `str | None` | The host to use for the ReactPy connections. If set to `None`, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir` | `None` |
| `**kwargs` | `Any` | The keyword arguments to provide to the component. | N/A |
**Returns**
@@ -73,6 +74,27 @@ The `component` template tag can be used to insert any number of ReactPy compone
```
+
+??? question "Can I render components on a different server (distributed computing)?"
+
+ Yes! By using the `host` keyword argument, you can render components from a completely separate ASGI server.
+
+ === "my-template.html"
+
+ ```jinja
+ ...
+ {% component "example_project.my_app.components.do_something" host="127.0.0.1:8001" %}
+ ...
+ ```
+
+ This configuration most commonly involves you deploying multiple instances of your project. But, you can also create dedicated Django project(s) that only render specific ReactPy components if you wish.
+
+ Here's a couple of things to keep in mind:
+
+ 1. If your host address are completely separate ( `origin1.com != origin2.com` ) you will need to [configure CORS headers](https://pypi.org/project/django-cors-headers/) on your main application during deployment.
+ 2. You will not need to register ReactPy HTTP or websocket paths on any applications that do not perform any component rendering.
+ 3. Your component will only be able to access `*args`/`**kwargs` you provide to the template tag if your applications share a common database.
+
??? question "Can I use multiple components on one page?"
@@ -98,7 +120,6 @@ The `component` template tag can be used to insert any number of ReactPy compone
Additionally, in scenarios where you are trying to create a Single Page Application (SPA) within Django, you will only have one component within your `#!html
` tag.
-
??? question "Can I use positional arguments instead of keyword arguments?"
diff --git a/docs/src/features/utils.md b/docs/src/features/utils.md
index 9cec1aa4..dfadb9f9 100644
--- a/docs/src/features/utils.md
+++ b/docs/src/features/utils.md
@@ -37,3 +37,25 @@ This postprocessor is designed to avoid Django's `SynchronousOnlyException` by r
| Type | Description |
| --- | --- |
| `QuerySet | Model` | The `Model` or `QuerySet` with all fields fetched. |
+
+## Register Component
+
+The `register_component` function is used manually register a root component with ReactPy.
+
+You should always call `register_component` within a Django [`AppConfig.ready()` method](https://docs.djangoproject.com/en/4.2/ref/applications/#django.apps.AppConfig.ready) to retain compatibility with ASGI webserver workers.
+
+=== "apps.py"
+
+ ```python
+ {% include "../../python/register-component.py" %}
+ ```
+
+??? question "Do I need to register my components?"
+
+ You typically will not need to use this function.
+
+ For security reasons, ReactPy does not allow non-registered components to be root components. However, all components contained within Django templates are automatically considered root components.
+
+ You only need to use this function if your host application does not contain any HTML templates that [reference](../features/template-tag.md#component) your components.
+
+ A common scenario where this is needed is when you are modifying the [template tag `host = ...` argument](../features/template-tag.md#component) in order to configure a dedicated Django application as a rendering server for ReactPy. On this dedicated rendering server, you would need to manually register your components.
diff --git a/docs/src/get-started/installation.md b/docs/src/get-started/installation.md
index d9763992..bd368ec1 100644
--- a/docs/src/get-started/installation.md
+++ b/docs/src/get-started/installation.md
@@ -44,11 +44,7 @@ In your settings you will need to add `reactpy_django` to [`INSTALLED_APPS`](htt
??? note "Configure ReactPy settings (Optional)"
- Below are a handful of values you can change within `settings.py` to modify the behavior of ReactPy.
-
- ```python linenums="0"
- {% include "../../python/settings.py" %}
- ```
+ {% include "../features/settings.md" start="" end="" %}
## Step 3: Configure [`urls.py`](https://docs.djangoproject.com/en/dev/topics/http/urls/)
@@ -62,7 +58,7 @@ Add ReactPy HTTP paths to your `urlpatterns`.
## Step 4: Configure [`asgi.py`](https://docs.djangoproject.com/en/dev/howto/deployment/asgi/)
-Register ReactPy's Websocket using `REACTPY_WEBSOCKET_PATH`.
+Register ReactPy's Websocket using `REACTPY_WEBSOCKET_ROUTE`.
=== "asgi.py"
@@ -95,3 +91,11 @@ Run Django's database migrations to initialize ReactPy-Django's database table.
```bash linenums="0"
python manage.py migrate
```
+
+## Step 6: Check your configuration
+
+Run Django's check command to verify if ReactPy was set up correctly.
+
+```bash linenums="0"
+python manage.py check
+```
diff --git a/pyproject.toml b/pyproject.toml
index 5cd9c3a7..0f9e87a3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,15 +3,13 @@ requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"
[tool.mypy]
-exclude = [
- 'migrations/.*',
-]
+exclude = ['migrations/.*']
ignore_missing_imports = true
warn_unused_configs = true
warn_redundant_casts = true
warn_unused_ignores = true
check_untyped_defs = true
-incremental = false
+incremental = true
[tool.ruff.isort]
known-first-party = ["src", "tests"]
diff --git a/src/js/src/index.js b/src/js/src/index.js
index 64684d7a..2ee74e07 100644
--- a/src/js/src/index.js
+++ b/src/js/src/index.js
@@ -1,34 +1,59 @@
import { mountLayoutWithWebSocket } from "@reactpy/client";
// Set up a websocket at the base endpoint
-const LOCATION = window.location;
+let HTTP_PROTOCOL = window.location.protocol;
let WS_PROTOCOL = "";
-if (LOCATION.protocol == "https:") {
- WS_PROTOCOL = "wss://";
+if (HTTP_PROTOCOL == "https:") {
+ WS_PROTOCOL = "wss:";
} else {
- WS_PROTOCOL = "ws://";
+ WS_PROTOCOL = "ws:";
}
-const WS_ENDPOINT_URL = WS_PROTOCOL + LOCATION.host + "/";
export function mountViewToElement(
mountElement,
- reactpyWebsocketUrl,
- reactpyWebModulesUrl,
- maxReconnectTimeout,
- componentPath
+ reactpyHost,
+ reactpyUrlPrefix,
+ reactpyReconnectMax,
+ reactpyComponentPath,
+ reactpyResolvedWebModulesPath
) {
- const WS_URL = WS_ENDPOINT_URL + reactpyWebsocketUrl + componentPath;
- const WEB_MODULE_URL = LOCATION.origin + "/" + reactpyWebModulesUrl;
+ // Determine the Websocket route
+ let wsOrigin;
+ if (reactpyHost) {
+ wsOrigin = `${WS_PROTOCOL}//${reactpyHost}`;
+ } else {
+ wsOrigin = `${WS_PROTOCOL}//${window.location.host}`;
+ }
+ const websocketUrl = `${wsOrigin}/${reactpyUrlPrefix}/${reactpyComponentPath}`;
+
+ // Determine the HTTP route
+ let httpOrigin;
+ let webModulesPath;
+ if (reactpyHost) {
+ httpOrigin = `${HTTP_PROTOCOL}//${reactpyHost}`;
+ webModulesPath = `${reactpyUrlPrefix}/web_module`;
+ } else {
+ httpOrigin = `${HTTP_PROTOCOL}//${window.location.host}`;
+ if (reactpyResolvedWebModulesPath) {
+ webModulesPath = reactpyResolvedWebModulesPath;
+ } else {
+ webModulesPath = `${reactpyUrlPrefix}/web_module`;
+ }
+ }
+ const webModuleUrl = `${httpOrigin}/${webModulesPath}`;
+
+ // Function that loads the JavaScript web module, if needed
const loadImportSource = (source, sourceType) => {
return import(
- sourceType == "NAME" ? `${WEB_MODULE_URL}${source}` : source
+ sourceType == "NAME" ? `${webModuleUrl}/${source}` : source
);
};
+ // Start rendering the component
mountLayoutWithWebSocket(
mountElement,
- WS_URL,
+ websocketUrl,
loadImportSource,
- maxReconnectTimeout
+ reactpyReconnectMax
);
}
diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py
index fc3940e8..cfbbae80 100644
--- a/src/reactpy_django/__init__.py
+++ b/src/reactpy_django/__init__.py
@@ -3,11 +3,15 @@
import nest_asyncio
from reactpy_django import checks, components, decorators, hooks, types, utils
-from reactpy_django.websocket.paths import REACTPY_WEBSOCKET_PATH
+from reactpy_django.websocket.paths import (
+ REACTPY_WEBSOCKET_PATH,
+ REACTPY_WEBSOCKET_ROUTE,
+)
__version__ = "3.3.2"
__all__ = [
"REACTPY_WEBSOCKET_PATH",
+ "REACTPY_WEBSOCKET_ROUTE",
"hooks",
"components",
"decorators",
diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py
index fc5a89a6..7ab9546e 100644
--- a/src/reactpy_django/checks.py
+++ b/src/reactpy_django/checks.py
@@ -1,8 +1,10 @@
+import contextlib
import sys
from django.contrib.staticfiles.finders import find
from django.core.checks import Error, Tags, Warning, register
from django.template import loader
+from django.urls import NoReverseMatch
@register(Tags.compatibility)
@@ -10,6 +12,7 @@ def reactpy_warnings(app_configs, **kwargs):
from django.conf import settings
from django.urls import reverse
+ from reactpy_django import config
from reactpy_django.config import REACTPY_FAILED_COMPONENTS
warnings = []
@@ -40,22 +43,24 @@ def reactpy_warnings(app_configs, **kwargs):
Warning(
"ReactPy URLs have not been registered.",
hint="""Add 'path("reactpy/", include("reactpy_django.http.urls"))' """
- "to your application's urlpatterns.",
+ "to your application's urlpatterns. If this application does not need "
+ "to render ReactPy components, you add this warning to SILENCED_SYSTEM_CHECKS.",
id="reactpy_django.W002",
)
)
- # Warn if REACTPY_BACKHAUL_THREAD is set to True on Linux with Daphne
+ # Warn if REACTPY_BACKHAUL_THREAD is set to True with Daphne
if (
- sys.argv
- and sys.argv[0].endswith("daphne")
- and getattr(settings, "REACTPY_BACKHAUL_THREAD", False)
- and sys.platform == "linux"
- ):
+ sys.argv[0].endswith("daphne")
+ or (
+ "runserver" in sys.argv
+ and "daphne" in getattr(settings, "INSTALLED_APPS", [])
+ )
+ ) and getattr(settings, "REACTPY_BACKHAUL_THREAD", False):
warnings.append(
Warning(
- "REACTPY_BACKHAUL_THREAD is enabled but you running with Daphne on Linux. "
- "This configuration is known to be unstable.",
+ "Unstable configuration detected. REACTPY_BACKHAUL_THREAD is enabled "
+ "and you running with Daphne.",
hint="Set settings.py:REACTPY_BACKHAUL_THREAD to False or use a different webserver.",
id="reactpy_django.W003",
)
@@ -96,28 +101,64 @@ def reactpy_warnings(app_configs, **kwargs):
)
)
- # Check if REACTPY_WEBSOCKET_URL doesn't end with a slash
- REACTPY_WEBSOCKET_URL = getattr(settings, "REACTPY_WEBSOCKET_URL", "reactpy/")
- if isinstance(REACTPY_WEBSOCKET_URL, str):
- if not REACTPY_WEBSOCKET_URL or not REACTPY_WEBSOCKET_URL.endswith("/"):
- warnings.append(
- Warning(
- "REACTPY_WEBSOCKET_URL did not end with a forward slash.",
- hint="Change your URL to be written in the following format: 'example_url/'",
- id="reactpy_django.W007",
- )
+ # DELETED W007: Check if REACTPY_WEBSOCKET_URL doesn't end with a slash
+ # DELETED W008: Check if REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character
+
+ # Removed Settings
+ if getattr(settings, "REACTPY_WEBSOCKET_URL", None):
+ warnings.append(
+ Warning(
+ "REACTPY_WEBSOCKET_URL has been removed.",
+ hint="Use REACTPY_URL_PREFIX instead.",
+ id="reactpy_django.W009",
)
+ )
- # Check if REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character
- if not REACTPY_WEBSOCKET_URL or not REACTPY_WEBSOCKET_URL[0].isalnum():
+ # Check if REACTPY_URL_PREFIX is being used properly in our HTTP URLs
+ with contextlib.suppress(NoReverseMatch):
+ full_path = reverse("reactpy:web_modules", kwargs={"file": "example"}).strip(
+ "/"
+ )
+ reactpy_http_prefix = f'{full_path[: full_path.find("web_module/")].strip("/")}'
+ if reactpy_http_prefix != config.REACTPY_URL_PREFIX:
warnings.append(
Warning(
- "REACTPY_WEBSOCKET_URL did not start with an alphanumeric character.",
- hint="Change your URL to be written in the following format: 'example_url/'",
- id="reactpy_django.W008",
+ "HTTP paths are not prefixed with REACTPY_URL_PREFIX. "
+ "Some ReactPy features may not work as expected.",
+ hint="Use one of the following solutions.\n"
+ "\t1) Utilize REACTPY_URL_PREFIX within your urls.py:\n"
+ f'\t path("{config.REACTPY_URL_PREFIX}/", include("reactpy_django.http.urls"))\n'
+ "\t2) Modify settings.py:REACTPY_URL_PREFIX to match your existing HTTP path:\n"
+ f'\t REACTPY_URL_PREFIX = "{reactpy_http_prefix}/"\n'
+ "\t3) If you not rendering components by this ASGI application, then remove "
+ "ReactPy HTTP and websocket routing. This is common for configurations that "
+ "rely entirely on `host` configuration in your template tag.",
+ id="reactpy_django.W010",
)
)
+ # Check if REACTPY_URL_PREFIX is empty
+ if not getattr(settings, "REACTPY_URL_PREFIX", "reactpy/"):
+ warnings.append(
+ Warning(
+ "REACTPY_URL_PREFIX should not be empty!",
+ hint="Change your REACTPY_URL_PREFIX to be written in the following format: '/example_url/'",
+ id="reactpy_django.W011",
+ )
+ )
+
+ # Check if `daphne` is not in installed apps when using `runserver`
+ if "runserver" in sys.argv and "daphne" not in getattr(
+ settings, "INSTALLED_APPS", []
+ ):
+ warnings.append(
+ Warning(
+ "You have not configured runserver to use ASGI.",
+ hint="Add daphne to settings.py:INSTALLED_APPS.",
+ id="reactpy_django.W012",
+ )
+ )
+
return warnings
@@ -154,12 +195,12 @@ def reactpy_errors(app_configs, **kwargs):
)
# All settings in reactpy_django.conf are the correct data type
- if not isinstance(getattr(settings, "REACTPY_WEBSOCKET_URL", ""), str):
+ if not isinstance(getattr(settings, "REACTPY_URL_PREFIX", ""), str):
errors.append(
Error(
- "Invalid type for REACTPY_WEBSOCKET_URL.",
- hint="REACTPY_WEBSOCKET_URL should be a string.",
- obj=settings.REACTPY_WEBSOCKET_URL,
+ "Invalid type for REACTPY_URL_PREFIX.",
+ hint="REACTPY_URL_PREFIX should be a string.",
+ obj=settings.REACTPY_URL_PREFIX,
id="reactpy_django.E003",
)
)
@@ -211,14 +252,30 @@ def reactpy_errors(app_configs, **kwargs):
)
)
- # Check for dependencies
- if "channels" not in settings.INSTALLED_APPS:
+ # DELETED E009: Check if `channels` is in INSTALLED_APPS
+
+ if not isinstance(getattr(settings, "REACTPY_DEFAULT_HOSTS", []), list):
errors.append(
Error(
- "Django Channels is not installed.",
- hint="Add 'channels' to settings.py:INSTALLED_APPS.",
- id="reactpy_django.E009",
+ "Invalid type for REACTPY_DEFAULT_HOSTS.",
+ hint="REACTPY_DEFAULT_HOSTS should be a list.",
+ obj=settings.REACTPY_DEFAULT_HOSTS,
+ id="reactpy_django.E010",
)
)
+ # Check of all values in the list are strings
+ if isinstance(getattr(settings, "REACTPY_DEFAULT_HOSTS", None), list):
+ for host in settings.REACTPY_DEFAULT_HOSTS:
+ if not isinstance(host, str):
+ errors.append(
+ Error(
+ f"Invalid type {type(host)} within REACTPY_DEFAULT_HOSTS.",
+ hint="REACTPY_DEFAULT_HOSTS should be a list of strings.",
+ obj=settings.REACTPY_DEFAULT_HOSTS,
+ id="reactpy_django.E011",
+ )
+ )
+ break
+
return errors
diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py
index c0ee14be..24aff5f6 100644
--- a/src/reactpy_django/config.py
+++ b/src/reactpy_django/config.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from itertools import cycle
+
from django.conf import settings
from django.core.cache import DEFAULT_CACHE_ALIAS
from django.db import DEFAULT_DB_ALIAS
@@ -20,13 +22,20 @@
REACTPY_VIEW_COMPONENT_IFRAMES: dict[str, ViewComponentIframe] = {}
-# Configurable through Django settings.py
+# Remove in a future release
REACTPY_WEBSOCKET_URL = getattr(
settings,
"REACTPY_WEBSOCKET_URL",
"reactpy/",
)
-REACTPY_RECONNECT_MAX = getattr(
+
+# Configurable through Django settings.py
+REACTPY_URL_PREFIX: str = getattr(
+ settings,
+ "REACTPY_URL_PREFIX",
+ REACTPY_WEBSOCKET_URL,
+).strip("/")
+REACTPY_RECONNECT_MAX: int = getattr(
settings,
"REACTPY_RECONNECT_MAX",
259200, # Default to 3 days
@@ -63,3 +72,13 @@
"REACTPY_BACKHAUL_THREAD",
False,
)
+_default_hosts: list[str] | None = getattr(
+ settings,
+ "REACTPY_DEFAULT_HOSTS",
+ None,
+)
+REACTPY_DEFAULT_HOSTS: cycle[str] | None = (
+ cycle([host.strip("/") for host in _default_hosts if isinstance(host, str)])
+ if _default_hosts
+ else None
+)
diff --git a/src/reactpy_django/exceptions.py b/src/reactpy_django/exceptions.py
index 072f1d4f..5cdcb719 100644
--- a/src/reactpy_django/exceptions.py
+++ b/src/reactpy_django/exceptions.py
@@ -4,3 +4,7 @@ class ComponentParamError(TypeError):
class ComponentDoesNotExistError(AttributeError):
...
+
+
+class InvalidHostError(ValueError):
+ ...
diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html
index 7dae08eb..4010b80f 100644
--- a/src/reactpy_django/templates/reactpy/component.html
+++ b/src/reactpy_django/templates/reactpy/component.html
@@ -4,16 +4,17 @@
{% firstof reactpy_error "UnknownError" %}: "{% firstof reactpy_dotted_path "UnknownPath" %}"
{% endif %}
{% else %}
-
+
{% endif %}
diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py
index d8e94e2b..c174d1b1 100644
--- a/src/reactpy_django/templatetags/reactpy.py
+++ b/src/reactpy_django/templatetags/reactpy.py
@@ -1,31 +1,38 @@
+from __future__ import annotations
+
from logging import getLogger
from uuid import uuid4
import dill as pickle
from django import template
-from django.urls import reverse
-
-from reactpy_django import models
-from reactpy_django.config import (
- REACTPY_DEBUG_MODE,
- REACTPY_RECONNECT_MAX,
- REACTPY_WEBSOCKET_URL,
+from django.http import HttpRequest
+from django.urls import NoReverseMatch, reverse
+
+from reactpy_django import config, models
+from reactpy_django.exceptions import (
+ ComponentDoesNotExistError,
+ ComponentParamError,
+ InvalidHostError,
)
-from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError
from reactpy_django.types import ComponentParamData
-from reactpy_django.utils import (
- _register_component,
- check_component_args,
- func_has_args,
-)
+from reactpy_django.utils import check_component_args, func_has_args
-REACTPY_WEB_MODULES_URL = reverse("reactpy:web_modules", args=["x"])[:-1][1:]
+try:
+ RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/")
+except NoReverseMatch:
+ RESOLVED_WEB_MODULES_PATH = ""
register = template.Library()
_logger = getLogger(__name__)
-@register.inclusion_tag("reactpy/component.html")
-def component(dotted_path: str, *args, **kwargs):
+@register.inclusion_tag("reactpy/component.html", takes_context=True)
+def component(
+ context: template.RequestContext,
+ dotted_path: str,
+ *args,
+ host: str | None = None,
+ **kwargs,
+):
"""This tag is used to embed an existing ReactPy component into your HTML template.
Args:
@@ -33,6 +40,9 @@ def component(dotted_path: str, *args, **kwargs):
*args: The positional arguments to provide to the component.
Keyword Args:
+ host: The host to use for the ReactPy connections. If set to `None`, \
+ the host will be automatically configured. \
+ Example values include: `localhost:8000`, `example.com`, `example.com/subdir`
**kwargs: The keyword arguments to provide to the component.
Example ::
@@ -46,33 +56,50 @@ def component(dotted_path: str, *args, **kwargs):
"""
- # Register the component if needed
- try:
- component = _register_component(dotted_path)
- uuid = uuid4().hex
- class_ = kwargs.pop("class", "")
- kwargs.pop("key", "") # `key` is effectively useless for the root node
-
- except Exception as e:
- if isinstance(e, ComponentDoesNotExistError):
- _logger.error(str(e))
- else:
- _logger.exception(
- "An unknown error has occurred while registering component '%s'.",
- dotted_path,
- )
- return failure_context(dotted_path, e)
-
- # Store the component's args/kwargs in the database if needed
- # This will be fetched by the websocket consumer later
+ # Determine the host
+ request: HttpRequest | None = context.get("request")
+ perceived_host = (request.get_host() if request else "").strip("/")
+ host = (
+ host
+ or (next(config.REACTPY_DEFAULT_HOSTS) if config.REACTPY_DEFAULT_HOSTS else "")
+ ).strip("/")
+
+ # Check if this this component needs to rendered by the current ASGI app
+ use_current_app = not host or host.startswith(perceived_host)
+
+ # Create context variables
+ uuid = uuid4().hex
+ class_ = kwargs.pop("class", "")
+ kwargs.pop("key", "") # `key` is effectively useless for the root node
+
+ # Fail if user has a method in their host
+ if host.find("://") != -1:
+ protocol = host.split("://")[0]
+ msg = (
+ f"Invalid host provided to component. Contains a protocol '{protocol}://'."
+ )
+ _logger.error(msg)
+ return failure_context(dotted_path, InvalidHostError(msg))
+
+ # Fetch the component if needed
+ if use_current_app:
+ user_component = config.REACTPY_REGISTERED_COMPONENTS.get(dotted_path)
+ if not user_component:
+ msg = f"Component '{dotted_path}' is not registered as a root component. "
+ _logger.error(msg)
+ return failure_context(dotted_path, ComponentDoesNotExistError(msg))
+
+ # Store the component's args/kwargs in the database, if needed
+ # These will be fetched by the websocket consumer later
try:
- check_component_args(component, *args, **kwargs)
- if func_has_args(component):
- params = ComponentParamData(args, kwargs)
- model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params))
- model.full_clean()
- model.save()
-
+ if use_current_app:
+ check_component_args(user_component, *args, **kwargs)
+ if func_has_args(user_component):
+ save_component_params(args, kwargs, uuid)
+ # Can't guarantee args will match up if the component is rendered by a different app.
+ # So, we just store any provided args/kwargs in the database.
+ elif args or kwargs:
+ save_component_params(args, kwargs, uuid)
except Exception as e:
if isinstance(e, ComponentParamError):
_logger.error(str(e))
@@ -85,19 +112,27 @@ def component(dotted_path: str, *args, **kwargs):
# Return the template rendering context
return {
- "class": class_,
- "reactpy_websocket_url": REACTPY_WEBSOCKET_URL,
- "reactpy_web_modules_url": REACTPY_WEB_MODULES_URL,
- "reactpy_reconnect_max": REACTPY_RECONNECT_MAX,
- "reactpy_mount_uuid": uuid,
+ "reactpy_class": class_,
+ "reactpy_uuid": uuid,
+ "reactpy_host": host or perceived_host,
+ "reactpy_url_prefix": config.REACTPY_URL_PREFIX,
+ "reactpy_reconnect_max": config.REACTPY_RECONNECT_MAX,
"reactpy_component_path": f"{dotted_path}/{uuid}/",
+ "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH,
}
def failure_context(dotted_path: str, error: Exception):
return {
"reactpy_failure": True,
- "reactpy_debug_mode": REACTPY_DEBUG_MODE,
+ "reactpy_debug_mode": config.REACTPY_DEBUG_MODE,
"reactpy_dotted_path": dotted_path,
"reactpy_error": type(error).__name__,
}
+
+
+def save_component_params(args, kwargs, uuid):
+ params = ComponentParamData(args, kwargs)
+ model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params))
+ model.full_clean()
+ model.save()
diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py
index f4e0f8e6..22844610 100644
--- a/src/reactpy_django/utils.py
+++ b/src/reactpy_django/utils.py
@@ -20,6 +20,7 @@
from django.utils import timezone
from django.utils.encoding import smart_str
from django.views import View
+from reactpy.types import ComponentConstructor
from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError
@@ -82,10 +83,8 @@ async def render_view(
return response
-def _register_component(dotted_path: str) -> Callable:
- """Adds a component to the mapping of registered components.
- This should only be called on startup to maintain synchronization during mulitprocessing.
- """
+def register_component(dotted_path: str) -> ComponentConstructor:
+ """Adds a component to the list of known registered components."""
from reactpy_django.config import (
REACTPY_FAILED_COMPONENTS,
REACTPY_REGISTERED_COMPONENTS,
@@ -101,7 +100,6 @@ def _register_component(dotted_path: str) -> Callable:
raise ComponentDoesNotExistError(
f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}."
) from e
- _logger.debug("ReactPy has registered component %s", dotted_path)
return REACTPY_REGISTERED_COMPONENTS[dotted_path]
@@ -204,16 +202,19 @@ def get_components(self, templates: set[str]) -> set[str]:
def register_components(self, components: set[str]) -> None:
"""Registers all ReactPy components in an iterable."""
+ if components:
+ _logger.debug("Auto-detected ReactPy root components:")
for component in components:
try:
- _logger.info("ReactPy preloader has detected component %s", component)
- _register_component(component)
+ _logger.debug("\t+ %s", component)
+ register_component(component)
except Exception:
_logger.exception(
"\033[91m"
- "ReactPy failed to register component '%s'! "
+ "ReactPy failed to register component '%s'!\n"
"This component path may not be valid, "
- "or an exception may have occurred while importing."
+ "or an exception may have occurred while importing.\n"
+ "See the traceback below for more information."
"\033[0m",
component,
)
@@ -296,15 +297,12 @@ def django_query_postprocessor(
return data
-def func_has_args(func: Callable) -> bool:
- """Checks if a function has any args or kwarg."""
- signature = inspect.signature(func)
-
- # Check if the function has any args/kwargs
- return str(signature) != "()"
+def func_has_args(func) -> bool:
+ """Checks if a function has any args or kwargs."""
+ return bool(inspect.signature(func).parameters)
-def check_component_args(func: Callable, *args, **kwargs):
+def check_component_args(func, *args, **kwargs):
"""
Validate whether a set of args/kwargs would work on the given function.
diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py
index 579f351b..aa0c2006 100644
--- a/src/reactpy_django/websocket/consumer.py
+++ b/src/reactpy_django/websocket/consumer.py
@@ -152,27 +152,18 @@ async def run_dispatcher(self):
# Fetch the component's args/kwargs from the database, if needed
try:
if func_has_args(component_constructor):
- try:
- # Always clean up expired entries first
- await database_sync_to_async(db_cleanup, thread_sensitive=False)()
-
- # Get the queries from a DB
- params_query = await models.ComponentSession.objects.aget(
- uuid=uuid,
- last_accessed__gt=now
- - timedelta(seconds=REACTPY_RECONNECT_MAX),
- )
- params_query.last_accessed = timezone.now()
- await database_sync_to_async(
- params_query.save, thread_sensitive=False
- )()
- except models.ComponentSession.DoesNotExist:
- await asyncio.to_thread(
- _logger.warning,
- f"Component session for '{dotted_path}:{uuid}' not found. The "
- "session may have already expired beyond REACTPY_RECONNECT_MAX.",
- )
- return
+ # Always clean up expired entries first
+ await database_sync_to_async(db_cleanup, thread_sensitive=False)()
+
+ # Get the queries from a DB
+ params_query = await models.ComponentSession.objects.aget(
+ uuid=uuid,
+ last_accessed__gt=now - timedelta(seconds=REACTPY_RECONNECT_MAX),
+ )
+ params_query.last_accessed = timezone.now()
+ await database_sync_to_async(
+ params_query.save, thread_sensitive=False
+ )()
component_params: ComponentParamData = pickle.loads(params_query.params)
component_args = component_params.args
component_kwargs = component_params.kwargs
@@ -181,6 +172,15 @@ async def run_dispatcher(self):
component_instance = component_constructor(
*component_args, **component_kwargs
)
+ except models.ComponentSession.DoesNotExist:
+ await asyncio.to_thread(
+ _logger.warning,
+ f"Component session for '{dotted_path}:{uuid}' not found. The "
+ "session may have already expired beyond REACTPY_RECONNECT_MAX. "
+ "If you are using a custom host, you may have forgotten to provide "
+ "args/kwargs.",
+ )
+ return
except Exception:
await asyncio.to_thread(
_logger.exception,
diff --git a/src/reactpy_django/websocket/paths.py b/src/reactpy_django/websocket/paths.py
index afd410c3..039ee5ba 100644
--- a/src/reactpy_django/websocket/paths.py
+++ b/src/reactpy_django/websocket/paths.py
@@ -1,15 +1,17 @@
from django.urls import path
-from reactpy_django.config import REACTPY_WEBSOCKET_URL
+from reactpy_django.config import REACTPY_URL_PREFIX
from .consumer import ReactpyAsyncWebsocketConsumer
-REACTPY_WEBSOCKET_PATH = path(
- f"{REACTPY_WEBSOCKET_URL}//",
+REACTPY_WEBSOCKET_ROUTE = path(
+ f"{REACTPY_URL_PREFIX}///",
ReactpyAsyncWebsocketConsumer.as_asgi(),
)
-
"""A URL path for :class:`ReactpyAsyncWebsocketConsumer`.
-Required in order for ReactPy to know the websocket path.
+Required since the `reverse()` function does not exist for Django Channels, but we need
+to know the websocket path.
"""
+
+REACTPY_WEBSOCKET_PATH = REACTPY_WEBSOCKET_ROUTE
diff --git a/tests/test_app/asgi.py b/tests/test_app/asgi.py
index b49d12ef..e372e42f 100644
--- a/tests/test_app/asgi.py
+++ b/tests/test_app/asgi.py
@@ -11,7 +11,6 @@
from django.core.asgi import get_asgi_application
-
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings")
# Fetch ASGI application before importing dependencies that require ORM models.
@@ -20,15 +19,13 @@
from channels.auth import AuthMiddlewareStack # noqa: E402
from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402
from channels.sessions import SessionMiddlewareStack # noqa: E402
-
-from reactpy_django import REACTPY_WEBSOCKET_PATH # noqa: E402
-
+from reactpy_django import REACTPY_WEBSOCKET_ROUTE # noqa: E402
application = ProtocolTypeRouter(
{
"http": http_asgi_app,
"websocket": SessionMiddlewareStack(
- AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_PATH]))
+ AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE]))
),
}
)
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index 433bd9e4..d018cd96 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -2,10 +2,13 @@
import inspect
from pathlib import Path
+import reactpy_django
from channels.db import database_sync_to_async
from django.http import HttpRequest
from django.shortcuts import render
from reactpy import component, hooks, html, web
+from reactpy_django.components import view_to_component
+
from test_app.models import (
AsyncForiegnChild,
AsyncRelationalChild,
@@ -17,9 +20,6 @@
TodoItem,
)
-import reactpy_django
-from reactpy_django.components import view_to_component
-
from . import views
from .types import TestObject
@@ -588,3 +588,17 @@ def view_to_component_decorator_args(request):
"view_to_component.html",
{"test_name": inspect.currentframe().f_code.co_name}, # type: ignore
)
+
+
+@component
+def custom_host(number=0):
+ scope = reactpy_django.hooks.use_scope()
+ port = scope["server"][1]
+
+ return html.div(
+ {
+ "class_name": f"{inspect.currentframe().f_code.co_name}-{number}", # type: ignore
+ "data-port": port,
+ },
+ f"Server Port: {port}",
+ )
diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py
index 10cd8f6b..a99da927 100644
--- a/tests/test_app/settings.py
+++ b/tests/test_app/settings.py
@@ -40,7 +40,6 @@
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
- "channels", # Websocket library
"reactpy_django", # Django compatiblity layer for ReactPy
"test_app", # This test application
]
@@ -161,6 +160,10 @@
]
# Logging
+LOG_LEVEL = "WARNING"
+if DEBUG and ("test" not in sys.argv):
+ LOG_LEVEL = "DEBUG"
+
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
@@ -172,7 +175,7 @@
"loggers": {
"reactpy_django": {
"handlers": ["console"],
- "level": "DEBUG" if DEBUG else "WARNING",
+ "level": LOG_LEVEL,
},
},
}
@@ -180,4 +183,4 @@
# ReactPy Django Settings
REACTPY_AUTH_BACKEND = "django.contrib.auth.backends.ModelBackend"
-REACTPY_BACKHAUL_THREAD = "test" not in sys.argv
+REACTPY_BACKHAUL_THREAD = "test" not in sys.argv and "runserver" not in sys.argv
diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html
index 10eaac27..303e99dd 100644
--- a/tests/test_app/templates/base.html
+++ b/tests/test_app/templates/base.html
@@ -89,6 +89,8 @@ ReactPy Test Page
{% component "test_app.components.hello_world" invalid_param="random_value" %}
+ {% component "test_app.components.hello_world" host="https://example.com/" %}
+