diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml
index 4439cb5e..09be8866 100644
--- a/.github/workflows/publish-py.yml
+++ b/.github/workflows/publish-py.yml
@@ -4,33 +4,33 @@
name: Publish Python
on:
- release:
- types: [published]
+ release:
+ types: [published]
jobs:
- release-package:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v3
- - uses: actions/setup-node@v3
- with:
- node-version: "14.x"
- - name: Set up Python
- uses: actions/setup-python@v1
- with:
- python-version: "3.x"
- - name: Install NPM
- run: |
- npm install -g npm@7.22.0
- npm --version
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -r requirements/build-pkg.txt
- - name: Build and publish
- env:
- TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
- TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
- run: |
- python -m build --sdist --wheel --outdir dist .
- twine upload dist/*
+ release-package:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
+ with:
+ node-version: "20.x"
+ - name: Set up Python
+ uses: actions/setup-python@v1
+ with:
+ python-version: "3.x"
+ - name: Install NPM
+ run: |
+ npm install -g npm@latest
+ npm --version
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements/build-pkg.txt
+ - name: Build and publish
+ env:
+ TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
+ TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
+ run: |
+ python -m build --sdist --wheel --outdir dist .
+ twine upload dist/*
diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml
index b58cb0ed..a0c8861d 100644
--- a/.github/workflows/publish-release-docs.yml
+++ b/.github/workflows/publish-release-docs.yml
@@ -20,4 +20,3 @@ jobs:
git config user.name github-actions
git config user.email github-actions@github.com
mike deploy --push --update-aliases ${{ github.event.release.name }} latest
- mike set-default --push latest
diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml
index f945ea9d..4a03c49c 100644
--- a/.github/workflows/test-src.yml
+++ b/.github/workflows/test-src.yml
@@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
- node-version: "14.x"
+ node-version: "20.x"
- name: Use Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 013688c7..da060bbd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,11 @@ Using the following categories, list your changes in this order:
### Added
+- More customization for reconnection behavior through new settings!
+ - `REACTPY_RECONNECT_INTERVAL`
+ - `REACTPY_RECONNECT_MAX_INTERVAL`
+ - `REACTPY_RECONNECT_MAX_RETRIES`
+ - `REACTPY_RECONNECT_BACKOFF_MULTIPLIER`
- [ReactPy-Django docs](https://reactive-python.github.io/reactpy-django/) are now version controlled via [mike](https://github.com/jimporter/mike)!
### Changed
@@ -43,6 +48,14 @@ Using the following categories, list your changes in this order:
- Bumped the minimum ReactPy version to `1.0.2`.
- Prettier websocket URLs for components that do not have sessions.
- Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled.
+- Bumped the minimum `@reactpy/client` version to `0.3.1`
+- Use TypeScript instead of JavaScript for this repository.
+- Bumped minimum Django version to `4.2`.
+ - Note: ReactPy-Django will continue bumping minimum Django requirements to versions that increase async support. This "latest-only" trend will continue until Django has all async features that ReactPy benefits from. After this point, ReactPy-Django will begin supporting all maintained Django versions.
+
+### Removed
+
+- `settings.py:REACTPY_RECONNECT_MAX` is removed. See the docs for the new `REACTPY_RECONNECT_*` settings.
## [3.4.0] - 2023-08-18
diff --git a/docs/src/contribute/code.md b/docs/src/contribute/code.md
index cc5b8e58..a5b52955 100644
--- a/docs/src/contribute/code.md
+++ b/docs/src/contribute/code.md
@@ -45,7 +45,7 @@ cd tests
python manage.py runserver
```
-Navigate to `http://127.0.0.1:8000` to see if the tests are rendering correctly.
+Navigate to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) to see if the tests are rendering correctly.
## GitHub Pull Request
diff --git a/docs/src/contribute/docs.md b/docs/src/contribute/docs.md
index 33b83fb7..913bc0a6 100644
--- a/docs/src/contribute/docs.md
+++ b/docs/src/contribute/docs.md
@@ -37,7 +37,7 @@ Finally, to verify that everything is working properly, you can manually run the
mkdocs serve
```
-Navigate to `http://127.0.0.1:8000` to view a preview of the documentation.
+Navigate to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) to view a preview of the documentation.
## GitHub Pull Request
diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md
index 8cf11815..00662976 100644
--- a/docs/src/features/settings.md
+++ b/docs/src/features/settings.md
@@ -24,11 +24,15 @@ These are ReactPy-Django's default settings values. You can modify these values
| --- | --- | --- | --- |
| `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 used to store ReactPy session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to use our database router 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_SESSION_MAX_AGE` | `#!python 259200` | `#!python 0`, `#!python 60`, `#!python 96000` | Maximum seconds to store ReactPy session data, such as `args` and `kwargs` passed into your component template tag.
Use `#!python 0` to not store any session data. |
| `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" ]` | The default host(s) that can render your 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) as a manual override. |
+| `REACTPY_RECONNECT_INTERVAL` | `#!python 750` | `#!python 100`, `#!python 2500`, `#!python 6000` | Milliseconds between client reconnection attempts. This value will gradually increase if `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` is greater than `#!python 1`. |
+| `REACTPY_RECONNECT_MAX_INTERVAL` | `#!python 60000` | `#!python 10000`, `#!python 25000`, `#!python 900000` | Maximum milliseconds between client reconnection attempts. This allows setting an upper bound on how high `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` can increase the time between reconnection attempts. |
+| `REACTPY_RECONNECT_MAX_RETRIES` | `#!python 150` | `#!python 0`, `#!python 5`, `#!python 300` | Maximum number of reconnection attempts before the client gives up. |
+| `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` | `#!python 1.25` | `#!python 1`, `#!python 1.5`, `#!python 3` | Multiplier for the time between client reconnection attempts. On each reconnection attempt, the `REACTPY_RECONNECT_INTERVAL` will be multiplied by this to increase the time between attempts. You can keep time between each reconnection the same by setting this to `#!python 1`. |
diff --git a/docs/src/get-started/run-webserver.md b/docs/src/get-started/run-webserver.md
index cefeafc3..cb4f87f1 100644
--- a/docs/src/get-started/run-webserver.md
+++ b/docs/src/get-started/run-webserver.md
@@ -16,7 +16,7 @@ To test your new Django view, run the following command to start up a developmen
python manage.py runserver
```
-Now you can navigate to your **Django project** URL that contains a ReactPy component, such as `http://127.0.0.1:8000/example/` ([_from the previous step_](./register-view.md)).
+Now you can navigate to your **Django project** URL that contains a ReactPy component, such as [`http://127.0.0.1:8000/example/`](http://127.0.0.1:8000/example/) ([_from the previous step_](./register-view.md)).
If you copy-pasted our example component, you will now see your component display "Hello World".
diff --git a/pyproject.toml b/pyproject.toml
index 0f9e87a3..250a64a6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,7 +9,6 @@ warn_unused_configs = true
warn_redundant_casts = true
warn_unused_ignores = true
check_untyped_defs = true
-incremental = true
[tool.ruff.isort]
known-first-party = ["src", "tests"]
diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt
index ee7156c3..8eecf7bc 100644
--- a/requirements/pkg-deps.txt
+++ b/requirements/pkg-deps.txt
@@ -1,5 +1,5 @@
channels >=4.0.0
-django >=4.1.0
+django >=4.2.0
reactpy >=1.0.2, <1.1.0
aiofile >=3.0
dill >=0.3.5
diff --git a/src/js/package-lock.json b/src/js/package-lock.json
index 024084a6..84321d16 100644
--- a/src/js/package-lock.json
+++ b/src/js/package-lock.json
@@ -5,14 +5,16 @@
"packages": {
"": {
"dependencies": {
- "@reactpy/client": "^0.1.0"
+ "@reactpy/client": "^0.3.1",
+ "@rollup/plugin-typescript": "^11.1.2",
+ "tslib": "^2.6.2"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.1",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-replace": "^5.0.2",
- "prettier": "^2.8.3",
- "rollup": "^3.12.0"
+ "prettier": "^3.0.2",
+ "rollup": "^3.28.1"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
@@ -22,16 +24,16 @@
"dev": true
},
"node_modules/@reactpy/client": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.1.0.tgz",
- "integrity": "sha512-GVsP23Re29JAbLNOBJytcem8paNhLj+2SZ8n9GlnlHPWuV6chAofT0aGveepCj1I9DdeVfRjDL6hfTreJEaDdg==",
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.3.1.tgz",
+ "integrity": "sha512-mvFwAvmRMgo7lTjkhkEJzBep6HX/wfm5BaNbtEMOUzto7G/h+z1AmqlOMXLH37DSI0iwfmCuNwy07EJM0JWZ0g==",
"dependencies": {
- "htm": "^3.0.3",
+ "event-to-object": "^0.1.2",
"json-pointer": "^0.6.2"
},
"peerDependencies": {
- "react": ">=16",
- "react-dom": ">=16"
+ "react": ">=16 <18",
+ "react-dom": ">=16 <18"
}
},
"node_modules/@rollup/plugin-commonjs": {
@@ -105,11 +107,35 @@
}
}
},
+ "node_modules/@rollup/plugin-typescript": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.2.tgz",
+ "integrity": "sha512-0ghSOCMcA7fl1JM+0gYRf+Q/HWyg+zg7/gDSc+fRLmlJWcW5K1I+CLRzaRhXf4Y3DRyPnnDo4M2ktw+a6JcDEg==",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.14.0||^3.0.0",
+ "tslib": "*",
+ "typescript": ">=3.7.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ },
+ "tslib": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rollup/pluginutils": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz",
"integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==",
- "dev": true,
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
@@ -130,8 +156,7 @@
"node_modules/@types/estree": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz",
- "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==",
- "dev": true
+ "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ=="
},
"node_modules/@types/resolve": {
"version": "1.20.2",
@@ -184,8 +209,15 @@
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
- "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
- "dev": true
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+ },
+ "node_modules/event-to-object": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/event-to-object/-/event-to-object-0.1.2.tgz",
+ "integrity": "sha512-+fUmp1XOCZiYomwe5Zxp4IlchuZZfdVdjFUk5MbgRT4M+V2TEWKc0jJwKLCX/nxlJ6xM5VUb/ylzERh7YDCRrg==",
+ "dependencies": {
+ "json-pointer": "^0.6.2"
+ }
},
"node_modules/foreach": {
"version": "2.0.6",
@@ -215,8 +247,7 @@
"node_modules/function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
- "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
- "dev": true
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"node_modules/glob": {
"version": "8.1.0",
@@ -241,7 +272,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
- "dev": true,
"dependencies": {
"function-bind": "^1.1.1"
},
@@ -249,11 +279,6 @@
"node": ">= 0.4.0"
}
},
- "node_modules/htm": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz",
- "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q=="
- },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -289,7 +314,6 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
"integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==",
- "dev": true,
"dependencies": {
"has": "^1.0.3"
},
@@ -383,14 +407,12 @@
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
- "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
- "dev": true
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true,
"engines": {
"node": ">=8.6"
},
@@ -399,15 +421,15 @@
}
},
"node_modules/prettier": {
- "version": "2.8.3",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.3.tgz",
- "integrity": "sha512-tJ/oJ4amDihPoufT5sM0Z1SKEuKay8LfVAMlbbhnnkvt6BUserZylqo2PN+p9KeljLr0OHa2rXHU1T8reeoTrw==",
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz",
+ "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==",
"dev": true,
"bin": {
- "prettier": "bin-prettier.js"
+ "prettier": "bin/prettier.cjs"
},
"engines": {
- "node": ">=10.13.0"
+ "node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
@@ -444,7 +466,6 @@
"version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
"integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
- "dev": true,
"dependencies": {
"is-core-module": "^2.9.0",
"path-parse": "^1.0.7",
@@ -458,10 +479,10 @@
}
},
"node_modules/rollup": {
- "version": "3.12.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.12.0.tgz",
- "integrity": "sha512-4MZ8kA2HNYahIjz63rzrMMRvDqQDeS9LoriJvMuV0V6zIGysP36e9t4yObUfwdT9h/szXoHQideICftcdZklWg==",
- "dev": true,
+ "version": "3.28.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.1.tgz",
+ "integrity": "sha512-R9OMQmIHJm9znrU3m3cpE8uhN0fGdXiawME7aZIpQqvpS/85+Vt1Hq1/yVIcYfOmaQiHjvXkQAoJukvLpau6Yw==",
+ "devOptional": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -487,7 +508,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
- "dev": true,
"engines": {
"node": ">= 0.4"
},
@@ -495,6 +515,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/tslib": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+ },
+ "node_modules/typescript": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
+ "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -510,11 +548,11 @@
"dev": true
},
"@reactpy/client": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.1.0.tgz",
- "integrity": "sha512-GVsP23Re29JAbLNOBJytcem8paNhLj+2SZ8n9GlnlHPWuV6chAofT0aGveepCj1I9DdeVfRjDL6hfTreJEaDdg==",
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.3.1.tgz",
+ "integrity": "sha512-mvFwAvmRMgo7lTjkhkEJzBep6HX/wfm5BaNbtEMOUzto7G/h+z1AmqlOMXLH37DSI0iwfmCuNwy07EJM0JWZ0g==",
"requires": {
- "htm": "^3.0.3",
+ "event-to-object": "^0.1.2",
"json-pointer": "^0.6.2"
}
},
@@ -556,11 +594,19 @@
"magic-string": "^0.27.0"
}
},
+ "@rollup/plugin-typescript": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.2.tgz",
+ "integrity": "sha512-0ghSOCMcA7fl1JM+0gYRf+Q/HWyg+zg7/gDSc+fRLmlJWcW5K1I+CLRzaRhXf4Y3DRyPnnDo4M2ktw+a6JcDEg==",
+ "requires": {
+ "@rollup/pluginutils": "^5.0.1",
+ "resolve": "^1.22.1"
+ }
+ },
"@rollup/pluginutils": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz",
"integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==",
- "dev": true,
"requires": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
@@ -570,8 +616,7 @@
"@types/estree": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz",
- "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==",
- "dev": true
+ "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ=="
},
"@types/resolve": {
"version": "1.20.2",
@@ -615,8 +660,15 @@
"estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
- "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
- "dev": true
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+ },
+ "event-to-object": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/event-to-object/-/event-to-object-0.1.2.tgz",
+ "integrity": "sha512-+fUmp1XOCZiYomwe5Zxp4IlchuZZfdVdjFUk5MbgRT4M+V2TEWKc0jJwKLCX/nxlJ6xM5VUb/ylzERh7YDCRrg==",
+ "requires": {
+ "json-pointer": "^0.6.2"
+ }
},
"foreach": {
"version": "2.0.6",
@@ -639,8 +691,7 @@
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
- "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
- "dev": true
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"glob": {
"version": "8.1.0",
@@ -659,16 +710,10 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
- "dev": true,
"requires": {
"function-bind": "^1.1.1"
}
},
- "htm": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz",
- "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q=="
- },
"inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -698,7 +743,6 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
"integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==",
- "dev": true,
"requires": {
"has": "^1.0.3"
}
@@ -777,19 +821,17 @@
"path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
- "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
- "dev": true
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
},
"prettier": {
- "version": "2.8.3",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.3.tgz",
- "integrity": "sha512-tJ/oJ4amDihPoufT5sM0Z1SKEuKay8LfVAMlbbhnnkvt6BUserZylqo2PN+p9KeljLr0OHa2rXHU1T8reeoTrw==",
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz",
+ "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==",
"dev": true
},
"react": {
@@ -817,7 +859,6 @@
"version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
"integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
- "dev": true,
"requires": {
"is-core-module": "^2.9.0",
"path-parse": "^1.0.7",
@@ -825,10 +866,10 @@
}
},
"rollup": {
- "version": "3.12.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.12.0.tgz",
- "integrity": "sha512-4MZ8kA2HNYahIjz63rzrMMRvDqQDeS9LoriJvMuV0V6zIGysP36e9t4yObUfwdT9h/szXoHQideICftcdZklWg==",
- "dev": true,
+ "version": "3.28.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.1.tgz",
+ "integrity": "sha512-R9OMQmIHJm9znrU3m3cpE8uhN0fGdXiawME7aZIpQqvpS/85+Vt1Hq1/yVIcYfOmaQiHjvXkQAoJukvLpau6Yw==",
+ "devOptional": true,
"requires": {
"fsevents": "~2.3.2"
}
@@ -846,8 +887,18 @@
"supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
- "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
- "dev": true
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="
+ },
+ "tslib": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+ },
+ "typescript": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
+ "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
+ "peer": true
},
"wrappy": {
"version": "1.0.2",
diff --git a/src/js/package.json b/src/js/package.json
index 92890671..40596a0d 100644
--- a/src/js/package.json
+++ b/src/js/package.json
@@ -1,6 +1,6 @@
{
"description": "reactpy-django client",
- "main": "src/index.js",
+ "main": "src/index.ts",
"type": "module",
"files": [
"src/**/*.js"
@@ -10,13 +10,15 @@
"format": "prettier --ignore-path .gitignore --write ."
},
"devDependencies": {
- "prettier": "^2.8.3",
- "rollup": "^3.12.0",
"@rollup/plugin-commonjs": "^24.0.1",
"@rollup/plugin-node-resolve": "^15.0.1",
- "@rollup/plugin-replace": "^5.0.2"
+ "@rollup/plugin-replace": "^5.0.2",
+ "prettier": "^3.0.2",
+ "rollup": "^3.28.1"
},
"dependencies": {
- "@reactpy/client": "^0.1.0"
+ "@reactpy/client": "^0.3.1",
+ "@rollup/plugin-typescript": "^11.1.2",
+ "tslib": "^2.6.2"
}
}
diff --git a/src/js/rollup.config.mjs b/src/js/rollup.config.mjs
index 50fbc637..79f93839 100644
--- a/src/js/rollup.config.mjs
+++ b/src/js/rollup.config.mjs
@@ -1,9 +1,10 @@
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import replace from "@rollup/plugin-replace";
+import typescript from "@rollup/plugin-typescript";
export default {
- input: "src/index.js",
+ input: "src/index.ts",
output: {
file: "../reactpy_django/static/reactpy_django/client.js",
format: "esm",
@@ -14,6 +15,7 @@ export default {
replace({
"process.env.NODE_ENV": JSON.stringify("production"),
}),
+ typescript(),
],
onwarn: function (warning) {
console.warn(warning.message);
diff --git a/src/js/src/client.ts b/src/js/src/client.ts
new file mode 100644
index 00000000..6f79df77
--- /dev/null
+++ b/src/js/src/client.ts
@@ -0,0 +1,31 @@
+import { BaseReactPyClient, ReactPyClient, ReactPyModule } from "@reactpy/client";
+import { createReconnectingWebSocket } from "./utils";
+import { ReactPyDjangoClientProps, ReactPyUrls } from "./types";
+
+export class ReactPyDjangoClient
+ extends BaseReactPyClient
+ implements ReactPyClient
+{
+ urls: ReactPyUrls;
+ socket: { current?: WebSocket };
+
+ constructor(props: ReactPyDjangoClientProps) {
+ super();
+ this.urls = props.urls;
+ this.socket = createReconnectingWebSocket({
+ readyPromise: this.ready,
+ url: this.urls.componentUrl,
+ onMessage: async ({ data }) =>
+ this.handleIncoming(JSON.parse(data)),
+ ...props.reconnectOptions,
+ });
+ }
+
+ sendMessage(message: any): void {
+ this.socket.current?.send(JSON.stringify(message));
+ }
+
+ loadModule(moduleName: string): Promise {
+ return import(`${this.urls.jsModules}/${moduleName}`);
+ }
+}
diff --git a/src/js/src/index.js b/src/js/src/index.js
deleted file mode 100644
index 2ee74e07..00000000
--- a/src/js/src/index.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { mountLayoutWithWebSocket } from "@reactpy/client";
-
-// Set up a websocket at the base endpoint
-let HTTP_PROTOCOL = window.location.protocol;
-let WS_PROTOCOL = "";
-if (HTTP_PROTOCOL == "https:") {
- WS_PROTOCOL = "wss:";
-} else {
- WS_PROTOCOL = "ws:";
-}
-
-export function mountViewToElement(
- mountElement,
- reactpyHost,
- reactpyUrlPrefix,
- reactpyReconnectMax,
- reactpyComponentPath,
- reactpyResolvedWebModulesPath
-) {
- // 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" ? `${webModuleUrl}/${source}` : source
- );
- };
-
- // Start rendering the component
- mountLayoutWithWebSocket(
- mountElement,
- websocketUrl,
- loadImportSource,
- reactpyReconnectMax
- );
-}
diff --git a/src/js/src/index.ts b/src/js/src/index.ts
new file mode 100644
index 00000000..53d67c6f
--- /dev/null
+++ b/src/js/src/index.ts
@@ -0,0 +1,59 @@
+import { mount } from "@reactpy/client";
+import { ReactPyDjangoClient } from "./client";
+
+export function mountComponent(
+ mountElement: HTMLElement,
+ host: string,
+ urlPrefix: string,
+ componentPath: string,
+ resolvedJsModulesPath: string,
+ reconnectStartInterval: number,
+ reconnectMaxInterval: number,
+ reconnectMaxRetries: number,
+ reconnectBackoffMultiplier: number
+) {
+ // Protocols
+ let httpProtocol = window.location.protocol;
+ let wsProtocol = `ws${httpProtocol === "https:" ? "s" : ""}:`;
+
+ // WebSocket route (for Python components)
+ let wsOrigin: string;
+ if (host) {
+ wsOrigin = `${wsProtocol}//${host}`;
+ } else {
+ wsOrigin = `${wsProtocol}//${window.location.host}`;
+ }
+
+ // HTTP route (for JavaScript modules)
+ let httpOrigin: string;
+ let jsModulesPath: string;
+ if (host) {
+ httpOrigin = `${httpProtocol}//${host}`;
+ jsModulesPath = `${urlPrefix}/web_module`;
+ } else {
+ httpOrigin = `${httpProtocol}//${window.location.host}`;
+ if (resolvedJsModulesPath) {
+ jsModulesPath = resolvedJsModulesPath;
+ } else {
+ jsModulesPath = `${urlPrefix}/web_module`;
+ }
+ }
+
+ // Configure a new ReactPy client
+ const client = new ReactPyDjangoClient({
+ urls: {
+ componentUrl: `${wsOrigin}/${urlPrefix}/${componentPath}`,
+ query: document.location.search,
+ jsModules: `${httpOrigin}/${jsModulesPath}`,
+ },
+ reconnectOptions: {
+ startInterval: reconnectStartInterval,
+ maxInterval: reconnectMaxInterval,
+ backoffMultiplier: reconnectBackoffMultiplier,
+ maxRetries: reconnectMaxRetries,
+ },
+ });
+
+ // Start rendering the component
+ mount(mountElement, client);
+}
diff --git a/src/js/src/types.ts b/src/js/src/types.ts
new file mode 100644
index 00000000..54a0b604
--- /dev/null
+++ b/src/js/src/types.ts
@@ -0,0 +1,17 @@
+export type ReconnectOptions = {
+ startInterval: number;
+ maxInterval: number;
+ maxRetries: number;
+ backoffMultiplier: number;
+}
+
+export type ReactPyUrls = {
+ componentUrl: string;
+ query: string;
+ jsModules: string;
+}
+
+export type ReactPyDjangoClientProps = {
+ urls: ReactPyUrls;
+ reconnectOptions: ReconnectOptions;
+}
diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts
new file mode 100644
index 00000000..a3f653ce
--- /dev/null
+++ b/src/js/src/utils.ts
@@ -0,0 +1,77 @@
+export function createReconnectingWebSocket(props: {
+ url: string;
+ readyPromise: Promise;
+ onOpen?: () => void;
+ onMessage: (message: MessageEvent) => void;
+ onClose?: () => void;
+ startInterval: number;
+ maxInterval: number;
+ maxRetries: number;
+ backoffMultiplier: number;
+}) {
+ const { startInterval, maxInterval, maxRetries, backoffMultiplier } = props;
+ let retries = 0;
+ let interval = startInterval;
+ let everConnected = false;
+ const closed = false;
+ const socket: { current?: WebSocket } = {};
+
+ const connect = () => {
+ if (closed) {
+ return;
+ }
+ socket.current = new WebSocket(props.url);
+ socket.current.onopen = () => {
+ everConnected = true;
+ console.info("ReactPy connected!");
+ interval = startInterval;
+ retries = 0;
+ if (props.onOpen) {
+ props.onOpen();
+ }
+ };
+ socket.current.onmessage = props.onMessage;
+ socket.current.onclose = () => {
+ if (!everConnected) {
+ console.info("ReactPy failed to connect!");
+ return;
+ }
+ console.info("ReactPy disconnected!");
+ if (props.onClose) {
+ props.onClose();
+ }
+ if (retries >= maxRetries) {
+ console.info("ReactPy connection max retries exhausted!");
+ return;
+ }
+ console.info(
+ `ReactPy reconnecting in ${(interval / 1000).toPrecision(
+ 4
+ )} seconds...`
+ );
+ setTimeout(connect, interval);
+ interval = nextInterval(interval, backoffMultiplier, maxInterval);
+ retries++;
+ };
+ };
+
+ props.readyPromise
+ .then(() => console.info("Starting ReactPy client..."))
+ .then(connect);
+
+ return socket;
+}
+
+export function nextInterval(
+ currentInterval: number,
+ backoffMultiplier: number,
+ maxInterval: number
+): number {
+ return Math.min(
+ currentInterval *
+ // increase interval by backoff multiplier
+ backoffMultiplier,
+ // don't exceed max interval
+ maxInterval
+ );
+}
diff --git a/src/js/tsconfig.json b/src/js/tsconfig.json
new file mode 100644
index 00000000..7da4aa77
--- /dev/null
+++ b/src/js/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "module": "esnext",
+ "moduleResolution": "node",
+ },
+}
diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py
index 7ab9546e..754b81be 100644
--- a/src/reactpy_django/checks.py
+++ b/src/reactpy_django/checks.py
@@ -1,4 +1,5 @@
import contextlib
+import math
import sys
from django.contrib.staticfiles.finders import find
@@ -104,7 +105,7 @@ def reactpy_warnings(app_configs, **kwargs):
# 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
+ # Removed REACTPY_WEBSOCKET_URL setting
if getattr(settings, "REACTPY_WEBSOCKET_URL", None):
warnings.append(
Warning(
@@ -159,6 +160,87 @@ def reactpy_warnings(app_configs, **kwargs):
)
)
+ # Removed REACTPY_RECONNECT_MAX setting
+ if getattr(settings, "REACTPY_RECONNECT_MAX", None):
+ warnings.append(
+ Warning(
+ "REACTPY_RECONNECT_MAX has been removed.",
+ hint="See the docs for the new REACTPY_RECONNECT_* settings.",
+ id="reactpy_django.W013",
+ )
+ )
+
+ if (
+ isinstance(config.REACTPY_RECONNECT_INTERVAL, int)
+ and config.REACTPY_RECONNECT_INTERVAL > 30000
+ ):
+ warnings.append(
+ Warning(
+ "REACTPY_RECONNECT_INTERVAL is set to >30 seconds. Are you sure this is intentional? "
+ "This may cause unexpected delays between reconnection.",
+ hint="Check your value for REACTPY_RECONNECT_INTERVAL or suppress this warning.",
+ id="reactpy_django.W014",
+ )
+ )
+
+ if (
+ isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int)
+ and config.REACTPY_RECONNECT_MAX_RETRIES > 5000
+ ):
+ warnings.append(
+ Warning(
+ "REACTPY_RECONNECT_MAX_RETRIES is set to a very large value. Are you sure this is intentional? "
+ "This may leave your clients attempting reconnections for a long time.",
+ hint="Check your value for REACTPY_RECONNECT_MAX_RETRIES or suppress this warning.",
+ id="reactpy_django.W015",
+ )
+ )
+
+ # Check if the value is too large (greater than 50)
+ if (
+ isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float))
+ and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER > 100
+ ):
+ warnings.append(
+ Warning(
+ "REACTPY_RECONNECT_BACKOFF_MULTIPLIER is set to a very large value. Are you sure this is intentional?",
+ hint="Check your value for REACTPY_RECONNECT_BACKOFF_MULTIPLIER or suppress this warning.",
+ id="reactpy_django.W016",
+ )
+ )
+
+ if (
+ isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int)
+ and isinstance(config.REACTPY_RECONNECT_INTERVAL, int)
+ and isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int)
+ and isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float))
+ and config.REACTPY_RECONNECT_INTERVAL > 0
+ and config.REACTPY_RECONNECT_MAX_INTERVAL > 0
+ and config.REACTPY_RECONNECT_MAX_RETRIES > 0
+ and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER > 1
+ and (
+ config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER
+ ** config.REACTPY_RECONNECT_MAX_RETRIES
+ )
+ * config.REACTPY_RECONNECT_INTERVAL
+ < config.REACTPY_RECONNECT_MAX_INTERVAL
+ ):
+ max_value = math.floor(
+ (
+ config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER
+ ** config.REACTPY_RECONNECT_MAX_RETRIES
+ )
+ * config.REACTPY_RECONNECT_INTERVAL
+ )
+ warnings.append(
+ Warning(
+ "Your current ReactPy configuration can never reach REACTPY_RECONNECT_MAX_INTERVAL. At most you will reach "
+ f"{max_value} miliseconds, which is less than {config.REACTPY_RECONNECT_MAX_INTERVAL} (REACTPY_RECONNECT_MAX_INTERVAL).",
+ hint="Check your ReactPy REACTPY_RECONNECT_* settings.",
+ id="reactpy_django.W017",
+ )
+ )
+
return warnings
@@ -166,6 +248,8 @@ def reactpy_warnings(app_configs, **kwargs):
def reactpy_errors(app_configs, **kwargs):
from django.conf import settings
+ from reactpy_django import config
+
errors = []
# Make sure ASGI is enabled
@@ -204,12 +288,12 @@ def reactpy_errors(app_configs, **kwargs):
id="reactpy_django.E003",
)
)
- if not isinstance(getattr(settings, "REACTPY_RECONNECT_MAX", 0), int):
+ if not isinstance(getattr(settings, "REACTPY_SESSION_MAX_AGE", 0), int):
errors.append(
Error(
- "Invalid type for REACTPY_RECONNECT_MAX.",
- hint="REACTPY_RECONNECT_MAX should be an integer.",
- obj=settings.REACTPY_RECONNECT_MAX,
+ "Invalid type for REACTPY_SESSION_MAX_AGE.",
+ hint="REACTPY_SESSION_MAX_AGE should be an integer.",
+ obj=settings.REACTPY_SESSION_MAX_AGE,
id="reactpy_django.E004",
)
)
@@ -278,4 +362,101 @@ def reactpy_errors(app_configs, **kwargs):
)
break
+ if not isinstance(config.REACTPY_RECONNECT_INTERVAL, int):
+ errors.append(
+ Error(
+ "Invalid type for REACTPY_RECONNECT_INTERVAL.",
+ hint="REACTPY_RECONNECT_INTERVAL should be an integer.",
+ id="reactpy_django.E012",
+ )
+ )
+
+ if (
+ isinstance(config.REACTPY_RECONNECT_INTERVAL, int)
+ and config.REACTPY_RECONNECT_INTERVAL < 0
+ ):
+ errors.append(
+ Error(
+ "Invalid value for REACTPY_RECONNECT_INTERVAL.",
+ hint="REACTPY_RECONNECT_INTERVAL should be a positive integer.",
+ id="reactpy_django.E013",
+ )
+ )
+
+ if not isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int):
+ errors.append(
+ Error(
+ "Invalid type for REACTPY_RECONNECT_MAX_INTERVAL.",
+ hint="REACTPY_RECONNECT_MAX_INTERVAL should be an integer.",
+ id="reactpy_django.E014",
+ )
+ )
+
+ if (
+ isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int)
+ and config.REACTPY_RECONNECT_MAX_INTERVAL < 0
+ ):
+ errors.append(
+ Error(
+ "Invalid value for REACTPY_RECONNECT_MAX_INTERVAL.",
+ hint="REACTPY_RECONNECT_MAX_INTERVAL should be a positive integer.",
+ id="reactpy_django.E015",
+ )
+ )
+
+ if (
+ isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int)
+ and isinstance(config.REACTPY_RECONNECT_INTERVAL, int)
+ and config.REACTPY_RECONNECT_MAX_INTERVAL < config.REACTPY_RECONNECT_INTERVAL
+ ):
+ errors.append(
+ Error(
+ "REACTPY_RECONNECT_MAX_INTERVAL is less than REACTPY_RECONNECT_INTERVAL.",
+ hint="REACTPY_RECONNECT_MAX_INTERVAL should be greater than or equal to REACTPY_RECONNECT_INTERVAL.",
+ id="reactpy_django.E016",
+ )
+ )
+
+ if not isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int):
+ errors.append(
+ Error(
+ "Invalid type for REACTPY_RECONNECT_MAX_RETRIES.",
+ hint="REACTPY_RECONNECT_MAX_RETRIES should be an integer.",
+ id="reactpy_django.E017",
+ )
+ )
+
+ if (
+ isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int)
+ and config.REACTPY_RECONNECT_MAX_RETRIES < 0
+ ):
+ errors.append(
+ Error(
+ "Invalid value for REACTPY_RECONNECT_MAX_RETRIES.",
+ hint="REACTPY_RECONNECT_MAX_RETRIES should be a positive integer.",
+ id="reactpy_django.E018",
+ )
+ )
+
+ if not isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)):
+ errors.append(
+ Error(
+ "Invalid type for REACTPY_RECONNECT_BACKOFF_MULTIPLIER.",
+ hint="REACTPY_RECONNECT_BACKOFF_MULTIPLIER should be an integer or float.",
+ id="reactpy_django.E019",
+ )
+ )
+
+ if (
+ isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float))
+ and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER < 1
+ ):
+ errors.append(
+ Error(
+ "Invalid value for REACTPY_RECONNECT_BACKOFF_MULTIPLIER.",
+ hint="REACTPY_RECONNECT_BACKOFF_MULTIPLIER should be greater than or equal to 1.",
+ id="reactpy_django.E020",
+ )
+ )
+
return errors
diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py
index 24aff5f6..d811f7dc 100644
--- a/src/reactpy_django/config.py
+++ b/src/reactpy_django/config.py
@@ -35,9 +35,9 @@
"REACTPY_URL_PREFIX",
REACTPY_WEBSOCKET_URL,
).strip("/")
-REACTPY_RECONNECT_MAX: int = getattr(
+REACTPY_SESSION_MAX_AGE: int = getattr(
settings,
- "REACTPY_RECONNECT_MAX",
+ "REACTPY_SESSION_MAX_AGE",
259200, # Default to 3 days
)
REACTPY_CACHE: str = getattr(
@@ -82,3 +82,23 @@
if _default_hosts
else None
)
+REACTPY_RECONNECT_INTERVAL: int = getattr(
+ settings,
+ "REACTPY_RECONNECT_INTERVAL",
+ 750, # Default to 0.75 seconds
+)
+REACTPY_RECONNECT_MAX_INTERVAL: int = getattr(
+ settings,
+ "REACTPY_RECONNECT_MAX_INTERVAL",
+ 60000, # Default to 60 seconds
+)
+REACTPY_RECONNECT_MAX_RETRIES: int = getattr(
+ settings,
+ "REACTPY_RECONNECT_MAX_RETRIES",
+ 150,
+)
+REACTPY_RECONNECT_BACKOFF_MULTIPLIER: float | int = getattr(
+ settings,
+ "REACTPY_RECONNECT_BACKOFF_MULTIPLIER",
+ 1.25, # Default to 25% backoff per connection attempt
+)
diff --git a/src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py b/src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py
new file mode 100644
index 00000000..488f660d
--- /dev/null
+++ b/src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.3 on 2023-08-23 19:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("reactpy_django", "0004_config"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="componentsession",
+ name="last_accessed",
+ field=models.DateTimeField(auto_now=True),
+ ),
+ ]
diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py
index 65152126..1fa69d2c 100644
--- a/src/reactpy_django/models.py
+++ b/src/reactpy_django/models.py
@@ -8,7 +8,7 @@ class ComponentSession(models.Model):
uuid = models.UUIDField(primary_key=True, editable=False, unique=True) # type: ignore
params = models.BinaryField(editable=False) # type: ignore
- last_accessed = models.DateTimeField(auto_now_add=True) # type: ignore
+ last_accessed = models.DateTimeField(auto_now=True) # type: ignore
class Config(models.Model):
diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html
index 4010b80f..75a65dbe 100644
--- a/src/reactpy_django/templates/reactpy/component.html
+++ b/src/reactpy_django/templates/reactpy/component.html
@@ -6,15 +6,18 @@
{% else %}
{% endif %}
diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py
index b174fa9a..82640ced 100644
--- a/src/reactpy_django/templatetags/reactpy.py
+++ b/src/reactpy_django/templatetags/reactpy.py
@@ -15,7 +15,7 @@
ComponentParamError,
InvalidHostError,
)
-from reactpy_django.types import ComponentParamData
+from reactpy_django.types import ComponentParams
from reactpy_django.utils import validate_component_args
try:
@@ -83,7 +83,7 @@ def component(
_logger.error(msg)
return failure_context(dotted_path, ComponentDoesNotExistError(msg))
- # Validate the component
+ # Validate the component args & kwargs
if is_local and config.REACTPY_DEBUG_MODE:
try:
validate_component_args(user_component, *args, **kwargs)
@@ -108,11 +108,14 @@ def component(
"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}/"
if component_has_args
else f"{dotted_path}/",
"reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH,
+ "reactpy_reconnect_interval": config.REACTPY_RECONNECT_INTERVAL,
+ "reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL,
+ "reactpy_reconnect_backoff_multiplier": config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER,
+ "reactpy_reconnect_max_retries": config.REACTPY_RECONNECT_MAX_RETRIES,
}
@@ -126,7 +129,7 @@ def failure_context(dotted_path: str, error: Exception):
def save_component_params(args, kwargs, uuid):
- params = ComponentParamData(args, kwargs)
+ params = ComponentParams(args, kwargs)
model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params))
model.full_clean()
model.save()
diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py
index 02fdec6e..ac6205e0 100644
--- a/src/reactpy_django/types.py
+++ b/src/reactpy_django/types.py
@@ -33,7 +33,7 @@
"SyncPostprocessor",
"QueryOptions",
"MutationOptions",
- "ComponentParamData",
+ "ComponentParams",
]
_Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]])
@@ -127,7 +127,7 @@ class MutationOptions:
@dataclass
-class ComponentParamData:
+class ComponentParams:
"""Container used for serializing component parameters.
This dataclass is pickled & stored in the database, then unpickled when needed."""
diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py
index 0776bc3b..755b3a05 100644
--- a/src/reactpy_django/utils.py
+++ b/src/reactpy_django/utils.py
@@ -324,29 +324,31 @@ def create_cache_key(*args):
return f"reactpy_django:{':'.join(str(arg) for arg in args)}"
-def db_cleanup(immediate: bool = False):
+def delete_expired_sessions(immediate: bool = False):
"""Deletes expired component sessions from the database.
- This function may be expanded in the future to include additional cleanup tasks."""
- from .config import REACTPY_DEBUG_MODE, REACTPY_RECONNECT_MAX
+ As a performance optimization, this is only run once every REACTPY_SESSION_MAX_AGE seconds.
+ """
+ from .config import REACTPY_DEBUG_MODE, REACTPY_SESSION_MAX_AGE
from .models import ComponentSession, Config
config = Config.load()
start_time = timezone.now()
cleaned_at = config.cleaned_at
- clean_needed_by = cleaned_at + timedelta(seconds=REACTPY_RECONNECT_MAX)
+ clean_needed_by = cleaned_at + timedelta(seconds=REACTPY_SESSION_MAX_AGE)
# Delete expired component parameters
if immediate or timezone.now() >= clean_needed_by:
- expiration_date = timezone.now() - timedelta(seconds=REACTPY_RECONNECT_MAX)
+ expiration_date = timezone.now() - timedelta(seconds=REACTPY_SESSION_MAX_AGE)
ComponentSession.objects.filter(last_accessed__lte=expiration_date).delete()
config.cleaned_at = timezone.now()
config.save()
# Check if cleaning took abnormally long
- clean_duration = timezone.now() - start_time
- if REACTPY_DEBUG_MODE and clean_duration.total_seconds() > 1:
- _logger.warning(
- "ReactPy has taken %s seconds to clean up expired component sessions. "
- "This may indicate a performance issue with your system, cache, or database.",
- clean_duration.total_seconds(),
- )
+ if REACTPY_DEBUG_MODE:
+ clean_duration = timezone.now() - start_time
+ if clean_duration.total_seconds() > 1:
+ _logger.warning(
+ "ReactPy has taken %s seconds to clean up expired component sessions. "
+ "This may indicate a performance issue with your system, cache, or database.",
+ clean_duration.total_seconds(),
+ )
diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py
index a2a76cab..c6a47c27 100644
--- a/src/reactpy_django/websocket/consumer.py
+++ b/src/reactpy_django/websocket/consumer.py
@@ -21,8 +21,8 @@
from reactpy.core.layout import Layout
from reactpy.core.serve import serve_layout
-from reactpy_django.types import ComponentParamData, ComponentWebsocket
-from reactpy_django.utils import db_cleanup
+from reactpy_django.types import ComponentParams, ComponentWebsocket
+from reactpy_django.utils import delete_expired_sessions
_logger = logging.getLogger(__name__)
backhaul_loop = asyncio.new_event_loop()
@@ -42,6 +42,7 @@ class ReactpyAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer):
async def connect(self) -> None:
"""The browser has connected."""
+ from reactpy_django import models
from reactpy_django.config import REACTPY_AUTH_BACKEND, REACTPY_BACKHAUL_THREAD
await super().connect()
@@ -80,6 +81,7 @@ async def connect(self) -> None:
# Start the component dispatcher
self.dispatcher: Future | asyncio.Task
self.threaded = REACTPY_BACKHAUL_THREAD
+ self.component_session: models.ComponentSession | None = None
if self.threaded:
if not backhaul_thread.is_alive():
await asyncio.to_thread(
@@ -95,6 +97,28 @@ async def connect(self) -> None:
async def disconnect(self, code: int) -> None:
"""The browser has disconnected."""
self.dispatcher.cancel()
+
+ if self.component_session:
+ # Clean up expired component sessions
+ try:
+ await database_sync_to_async(
+ delete_expired_sessions, thread_sensitive=False
+ )()
+ except Exception:
+ await asyncio.to_thread(
+ _logger.exception,
+ "ReactPy has failed to delete expired component sessions!",
+ )
+
+ # Update the last_accessed timestamp
+ try:
+ await self.component_session.asave()
+ except Exception:
+ await asyncio.to_thread(
+ _logger.exception,
+ "ReactPy has failed to save component session!",
+ )
+
await super().disconnect(code)
async def receive_json(self, content: Any, **_) -> None:
@@ -118,8 +142,8 @@ async def run_dispatcher(self):
"""Runs the main loop that performs component rendering tasks."""
from reactpy_django import models
from reactpy_django.config import (
- REACTPY_RECONNECT_MAX,
REACTPY_REGISTERED_COMPONENTS,
+ REACTPY_SESSION_MAX_AGE,
)
scope = self.scope
@@ -136,8 +160,8 @@ async def run_dispatcher(self):
carrier=ComponentWebsocket(self.close, self.disconnect, dotted_path),
)
now = timezone.now()
- component_args: Sequence[Any] = ()
- component_kwargs: MutableMapping[str, Any] = {}
+ component_session_args: Sequence[Any] = ()
+ component_session_kwargs: MutableMapping[str, Any] = {}
# Verify the component has already been registered
try:
@@ -152,31 +176,24 @@ async def run_dispatcher(self):
# Fetch the component's args/kwargs from the database, if needed
try:
if uuid:
- # 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(
+ # Get the component session from the DB
+ self.component_session = await models.ComponentSession.objects.aget(
uuid=uuid,
- last_accessed__gt=now - timedelta(seconds=REACTPY_RECONNECT_MAX),
+ last_accessed__gt=now - timedelta(seconds=REACTPY_SESSION_MAX_AGE),
)
- 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
+ params: ComponentParams = pickle.loads(self.component_session.params)
+ component_session_args = params.args
+ component_session_kwargs = params.kwargs
# Generate the initial component instance
component_instance = component_constructor(
- *component_args, **component_kwargs
+ *component_session_args, **component_session_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. "
+ "session may have already expired beyond REACTPY_SESSION_MAX_AGE. "
"If you are using a custom host, you may have forgotten to provide "
"args/kwargs.",
)
@@ -185,7 +202,7 @@ async def run_dispatcher(self):
await asyncio.to_thread(
_logger.exception,
f"Failed to construct component {component_constructor} "
- f"with parameters {component_kwargs}",
+ f"with args='{component_session_args}' kwargs='{component_session_kwargs}'!",
)
return
diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py
index 3bd23527..0c9a1b84 100644
--- a/tests/test_app/tests/test_database.py
+++ b/tests/test_app/tests/test_database.py
@@ -6,7 +6,7 @@
from django.test import TransactionTestCase
from reactpy_django import utils
from reactpy_django.models import ComponentSession
-from reactpy_django.types import ComponentParamData
+from reactpy_django.types import ComponentParams
class RoutedDatabaseTests(TransactionTestCase):
@@ -15,7 +15,7 @@ class RoutedDatabaseTests(TransactionTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
- utils.db_cleanup(immediate=True)
+ utils.delete_expired_sessions(immediate=True)
def test_component_params(self):
# Make sure the ComponentParams table is empty
@@ -31,15 +31,15 @@ def test_component_params(self):
# Force `params_1` to expire
from reactpy_django import config
- config.REACTPY_RECONNECT_MAX = 1
- sleep(config.REACTPY_RECONNECT_MAX + 0.1)
+ config.REACTPY_SESSION_MAX_AGE = 1
+ sleep(config.REACTPY_SESSION_MAX_AGE + 0.1)
# Create a new, non-expired component params
params_2 = self._save_params_to_db(2)
self.assertEqual(ComponentSession.objects.count(), 2)
# Delete the first component params based on expiration time
- utils.db_cleanup() # Don't use `immediate` to test cache timestamping logic
+ utils.delete_expired_sessions() # Don't use `immediate` to test timestamping logic
# Make sure `params_1` has expired
self.assertEqual(ComponentSession.objects.count(), 1)
@@ -47,9 +47,9 @@ def test_component_params(self):
pickle.loads(ComponentSession.objects.first().params), params_2 # type: ignore
)
- def _save_params_to_db(self, value: Any) -> ComponentParamData:
+ def _save_params_to_db(self, value: Any) -> ComponentParams:
db = list(self.databases)[0]
- param_data = ComponentParamData((value,), {"test_value": value})
+ param_data = ComponentParams((value,), {"test_value": value})
model = ComponentSession(uuid4().hex, params=pickle.dumps(param_data))
model.clean_fields()
model.clean()