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()