From 41641aab4efed18a1c3202cdc605ab291536d126 Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Fri, 18 Aug 2023 02:19:20 -0700 Subject: [PATCH] Allow users to modify component's host URLs (#172) ### 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 detected ReactPy root components. ### Deprecated - `reactpy_django.REACTPY_WEBSOCKET_PATH` is deprecated. The similar 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. --- .gitignore | 4 +- CHANGELOG.md | 24 +++- docs/python/configure-asgi-middleware.py | 4 +- docs/python/configure-asgi.py | 4 +- docs/python/register-component.py | 8 ++ docs/python/settings.py | 32 ----- docs/src/features/components.md | 2 +- docs/src/features/settings.md | 19 ++- docs/src/features/template-tag.md | 23 ++- docs/src/features/utils.md | 22 +++ docs/src/get-started/installation.md | 16 ++- pyproject.toml | 6 +- src/js/src/index.js | 53 +++++-- src/reactpy_django/__init__.py | 6 +- src/reactpy_django/checks.py | 123 +++++++++++----- src/reactpy_django/config.py | 23 ++- src/reactpy_django/exceptions.py | 4 + .../templates/reactpy/component.html | 9 +- src/reactpy_django/templatetags/reactpy.py | 131 +++++++++++------- src/reactpy_django/utils.py | 30 ++-- src/reactpy_django/websocket/consumer.py | 42 +++--- src/reactpy_django/websocket/paths.py | 12 +- tests/test_app/asgi.py | 7 +- tests/test_app/components.py | 20 ++- tests/test_app/settings.py | 9 +- tests/test_app/templates/base.html | 2 + tests/test_app/templates/host_port.html | 21 +++ .../templates/host_port_roundrobin.html | 23 +++ tests/test_app/tests/test_components.py | 84 ++++++++++- tests/test_app/urls.py | 6 +- tests/test_app/views.py | 31 +++++ 31 files changed, 584 insertions(+), 216 deletions(-) create mode 100644 docs/python/register-component.py delete mode 100644 docs/python/settings.py create mode 100644 tests/test_app/templates/host_port.html create mode 100644 tests/test_app/templates/host_port_roundrobin.html 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/" %}
+
diff --git a/tests/test_app/templates/host_port.html b/tests/test_app/templates/host_port.html new file mode 100644 index 00000000..1eb2be2a --- /dev/null +++ b/tests/test_app/templates/host_port.html @@ -0,0 +1,21 @@ +{% load static %} {% load reactpy %} + + + + + + + + + ReactPy + + + +

ReactPy Test Page

+
+ Custom Host ({{new_host}}): + {% component "test_app.components.custom_host" host=new_host number=0 %} +
+ + + diff --git a/tests/test_app/templates/host_port_roundrobin.html b/tests/test_app/templates/host_port_roundrobin.html new file mode 100644 index 00000000..ad2dada0 --- /dev/null +++ b/tests/test_app/templates/host_port_roundrobin.html @@ -0,0 +1,23 @@ +{% load static %} {% load reactpy %} + + + + + + + + + ReactPy + + + +

ReactPy Test Page

+
+ {% for count in count %} + Round-Robin Host: + {% component "test_app.components.custom_host" number=count %} +
+ {% endfor %} + + + diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 0801e28e..58e6c053 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,5 +1,6 @@ import asyncio import os +import socket import sys from functools import partial @@ -10,11 +11,10 @@ from django.db import connections from django.test.utils import modify_settings from playwright.sync_api import TimeoutError, sync_playwright - from reactpy_django.models import ComponentSession - -CLICK_DELAY = 250 if os.getenv("GITHUB_ACTIONS") else 25 # Delay in miliseconds. +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") +CLICK_DELAY = 250 if GITHUB_ACTIONS else 25 # Delay in miliseconds. class ComponentTests(ChannelsLiveServerTestCase): @@ -41,11 +41,17 @@ def setUpClass(cls): cls._server_process.ready.wait() cls._port = cls._server_process.port.value + # Open the second server process + cls._server_process2 = cls.ProtocolServerProcess(cls.host, get_application) + cls._server_process2.start() + cls._server_process2.ready.wait() + cls._port2 = cls._server_process2.port.value + # Open a Playwright browser window if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) cls.playwright = sync_playwright().start() - headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", 0))) + headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", not GITHUB_ACTIONS))) cls.browser = cls.playwright.chromium.launch(headless=not headed) cls.page = cls.browser.new_page() @@ -54,6 +60,10 @@ def tearDownClass(cls): # Close the Playwright browser cls.playwright.stop() + # Close the second server process + cls._server_process2.terminate() + cls._server_process2.join() + # Repurposed from ChannelsLiveServerTestCase._post_teardown cls._server_process.terminate() cls._server_process.join() @@ -293,3 +303,69 @@ def test_component_session_missing(self): query_exists = query.exists() os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") self.assertFalse(query_exists) + + def test_custom_host(self): + """Make sure that the component is rendered by a separate server.""" + new_page = self.browser.new_page() + try: + new_page.goto(f"{self.live_server_url}/port/{self._port2}/") + elem = new_page.locator(".custom_host-0") + elem.wait_for() + self.assertIn( + f"Server Port: {self._port2}", + elem.text_content(), + ) + finally: + new_page.close() + + def test_custom_host_wrong_port(self): + """Make sure that other ports are not rendering components.""" + new_page = self.browser.new_page() + try: + tmp_sock = socket.socket() + tmp_sock.bind((self._server_process.host, 0)) + random_port = tmp_sock.getsockname()[1] + new_page.goto(f"{self.live_server_url}/port/{random_port}/") + with self.assertRaises(TimeoutError): + new_page.locator(".custom_host").wait_for(timeout=1000) + finally: + new_page.close() + + def test_host_roundrobin(self): + """Verify if round-robin host selection is working.""" + new_page = self.browser.new_page() + try: + new_page.goto( + f"{self.live_server_url}/roundrobin/{self._port}/{self._port2}/8" + ) + elem0 = new_page.locator(".custom_host-0") + elem1 = new_page.locator(".custom_host-1") + elem2 = new_page.locator(".custom_host-2") + elem3 = new_page.locator(".custom_host-3") + + elem0.wait_for() + elem1.wait_for() + elem2.wait_for() + elem3.wait_for() + + current_ports = { + elem0.get_attribute("data-port"), + elem1.get_attribute("data-port"), + elem2.get_attribute("data-port"), + elem3.get_attribute("data-port"), + } + correct_ports = { + str(self._port), + str(self._port2), + } + + # There should only be two ports in the set + self.assertEqual(current_ports, correct_ports) + self.assertEqual(len(current_ports), 2) + finally: + new_page.close() + + def test_invalid_host_error(self): + broken_component = self.page.locator("#invalid_host_error") + broken_component.wait_for() + self.assertIn("InvalidHostError:", broken_component.text_content()) diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index f3621b3e..ea185971 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -20,7 +20,7 @@ from django.contrib import admin from django.urls import include, path -from .views import base_template +from .views import base_template, host_port_roundrobin_template, host_port_template class AccessUser: @@ -31,6 +31,10 @@ class AccessUser: urlpatterns = [ path("", base_template), + path("port//", host_port_template), + path( + "roundrobin////", host_port_roundrobin_template + ), path("", include("test_app.performance.urls")), path("reactpy/", include("reactpy_django.http.urls")), path("admin/", admin.site.urls), diff --git a/tests/test_app/views.py b/tests/test_app/views.py index 0d013b1a..689d8f8c 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -1,6 +1,8 @@ import inspect +from itertools import cycle from channels.db import database_sync_to_async +from django.http import HttpRequest from django.shortcuts import render from django.views.generic import TemplateView, View @@ -11,6 +13,35 @@ def base_template(request): return render(request, "base.html", {"my_object": TestObject(1)}) +def host_port_template(request: HttpRequest, port: int): + host = request.get_host().replace(str(request.get_port()), str(port)) + return render(request, "host_port.html", {"new_host": host}) + + +def host_port_roundrobin_template( + request: HttpRequest, port1: int, port2: int, count: int = 1 +): + from reactpy_django import config + + # Override ReactPy config to use round-robin hosts + original = config.REACTPY_DEFAULT_HOSTS + config.REACTPY_DEFAULT_HOSTS = cycle( + [ + f"{request.get_host().split(':')[0]}:{port1}", + f"{request.get_host().split(':')[0]}:{port2}", + ] + ) + html = render( + request, + "host_port_roundrobin.html", + {"count": range(max(count, 1))}, + ) + + # Reset ReactPy config + config.REACTPY_DEFAULT_HOSTS = original + return html + + def view_to_component_sync_func(request): return render( request,