Skip to content

Commit af730a8

Browse files
authored
Merge pull request #3742 from plotly/websocket-callbacks
[4.2] Add websocket callbacks
2 parents d2616a5 + 5825c2d commit af730a8

50 files changed

Lines changed: 5215 additions & 71 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.ai/ARCHITECTURE.md

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,11 @@ Special handling for Colab:
723723
- `background_callback_manager` - DiskcacheManager or CeleryManager
724724
- `on_error` - Global callback error handler
725725

726+
**WebSocket Callbacks:**
727+
- `websocket_callbacks` - Enable WebSocket for all callbacks (default: `False`). Requires FastAPI backend.
728+
- `websocket_allowed_origins` - List of allowed origins for WebSocket connections
729+
- `websocket_inactivity_timeout` - Disconnect WebSocket after inactivity period in ms (default: `300000` = 5 minutes). Set to `0` to disable.
730+
726731
### app.run() Parameters
727732

728733
- `host` - Server IP (default: `"127.0.0.1"`, env: `HOST`)
@@ -861,6 +866,177 @@ async def async_background(n_clicks):
861866

862867
Both DiskcacheManager and CeleryManager support async functions via `asyncio.run()`.
863868

869+
## WebSocket Callbacks
870+
871+
WebSocket callbacks use a persistent WebSocket connection instead of HTTP POST for callback execution. This reduces latency and connection overhead for applications with frequent callbacks.
872+
873+
### Requirements
874+
875+
- **FastAPI backend required**: WebSocket callbacks only work with FastAPI
876+
- **SharedWorker support**: Modern browsers (not IE)
877+
878+
### Usage
879+
880+
**Enable globally for all callbacks:**
881+
```python
882+
from fastapi import FastAPI
883+
from dash import Dash
884+
885+
server = FastAPI()
886+
app = Dash(__name__, server=server, websocket_callbacks=True)
887+
```
888+
889+
**Enable per-callback:**
890+
```python
891+
@app.callback(
892+
Output('output', 'children'),
893+
Input('input', 'value'),
894+
websocket=True # Use WebSocket for this callback only
895+
)
896+
def update(value):
897+
return f"Value: {value}"
898+
```
899+
900+
### Configuration
901+
902+
```python
903+
app = Dash(
904+
__name__,
905+
server=server,
906+
websocket_callbacks=True,
907+
websocket_inactivity_timeout=300000, # 5 minutes (default)
908+
websocket_allowed_origins=['https://example.com'],
909+
)
910+
```
911+
912+
- **`websocket_callbacks`** - Enable WebSocket for all callbacks (default: `False`)
913+
- **`websocket_inactivity_timeout`** - Close WebSocket after period of inactivity in milliseconds (default: `300000` = 5 minutes). Heartbeats do not count as activity. Set to `0` to disable timeout. Connection automatically reconnects when needed.
914+
- **`websocket_allowed_origins`** - List of allowed origins for WebSocket connections (security)
915+
916+
### Architecture
917+
918+
```
919+
┌─────────────────────────────────────────────────────────────────────────┐
920+
│ Browser Tab 1 Browser Tab 2
921+
│ ┌─────────────┐ ┌─────────────┐ │
922+
│ │ Renderer │ │ Renderer │ │
923+
│ └──────┬──────┘ └──────┬──────┘ │
924+
│ │ postMessage │ postMessage │
925+
│ └────────────┬───────────────────────┘ │
926+
│ ▼ │
927+
│ ┌─────────────────────┐ │
928+
│ │ SharedWorker │ (one per origin) │
929+
│ │ dash-ws-worker │ │
930+
│ └──────────┬──────────┘ │
931+
└────────────────────│────────────────────────────────────────────────────┘
932+
│ WebSocket
933+
934+
┌─────────────────────────────────────────────────────────────────────────┐
935+
│ Server (FastAPI) │
936+
│ WebSocket Endpoint: /_dash-ws-callback │
937+
└─────────────────────────────────────────────────────────────────────────┘
938+
```
939+
940+
**Connection & Reconnection Flow:**
941+
```
942+
Renderer SharedWorker Server
943+
│ │ │
944+
│──[CONNECT]──────────────────>│ │
945+
│ │──[WebSocket Connect]──>
946+
<─[CONNECTED]─────────────────│<─[Connected]───────────│
947+
│ │ │
948+
│──[CALLBACK_REQUEST]─────────>│──[callback request]───>
949+
<─[CALLBACK_RESPONSE]─────────│<─[callback response]───│
950+
│ │ │
951+
│ (inactivity) │ (heartbeat check) │
952+
│ │──[close 4001]─────────>
953+
<─[DISCONNECTED]──────────────│ │
954+
│ │ │
955+
│──[CALLBACK_REQUEST]─────────>│──[reconnect + send]───>
956+
<─[CALLBACK_RESPONSE]─────────│<─[response]────────────│
957+
```
958+
959+
- **SharedWorker**: Single WebSocket connection shared across browser tabs
960+
- **Heartbeat**: Periodic ping/pong to detect dead connections (30s interval)
961+
- **Inactivity timeout**: Closes connection after no actual callback activity (not heartbeats)
962+
- **Auto-reconnect**: Reconnects automatically when a callback is triggered after timeout
963+
964+
### Long-Running Callbacks with set_props/get_props
965+
966+
WebSocket callbacks can stream updates to the client during execution using `set_props()` and read current component values using `ctx.get_websocket()`:
967+
968+
```python
969+
import asyncio
970+
from dash import callback, Output, Input, set_props, ctx
971+
972+
@callback(
973+
Output('result', 'children'),
974+
Input('start-btn', 'n_clicks'),
975+
prevent_initial_call=True
976+
)
977+
async def long_running_task(n_clicks):
978+
ws = ctx.get_websocket()
979+
if not ws:
980+
return "WebSocket not available"
981+
982+
# Stream progress updates to the client
983+
for i in range(100):
984+
await asyncio.sleep(0.1)
985+
set_props('progress-bar', {'value': i + 1})
986+
set_props('status', {'children': f'Processing step {i + 1}/100...'})
987+
988+
# Read current value from another component
989+
current_value = await ws.get_prop('input-field', 'value')
990+
991+
return f"Completed! Input was: {current_value}"
992+
```
993+
994+
**API:**
995+
- `set_props(component_id, props_dict)` - Stream prop updates immediately to client
996+
- `ctx.get_websocket()` - Get WebSocket interface (returns `None` if not in WS context)
997+
- `await ws.get_prop(component_id, prop_name)` - Read current prop value from client
998+
- `await ws.set_prop(component_id, prop_name, value)` - Set single prop (async version)
999+
- `await ws.close(code, reason)` - Close the WebSocket connection
1000+
1001+
### Connection Hooks
1002+
1003+
Use hooks to validate connections and messages:
1004+
1005+
```python
1006+
from dash import Dash, hooks
1007+
1008+
@hooks.websocket_connect()
1009+
async def validate_connection(websocket):
1010+
"""Validate WebSocket connection before accepting."""
1011+
session_id = websocket.cookies.get("session_id")
1012+
if not session_id:
1013+
return (4001, "No session cookie")
1014+
if not await is_valid_session(session_id):
1015+
return (4002, "Invalid session")
1016+
return True # Allow connection
1017+
1018+
@hooks.websocket_message()
1019+
async def validate_message(websocket, message):
1020+
"""Validate each WebSocket message."""
1021+
session_id = websocket.cookies.get("session_id")
1022+
if not await is_session_active(session_id):
1023+
return (4002, "Session expired")
1024+
return True # Allow message
1025+
```
1026+
1027+
**Hook Return Values:**
1028+
- `True` (or truthy) - Allow connection/message
1029+
- `False` - Reject with default code (4001)
1030+
- `(code, reason)` - Reject with custom close code and reason
1031+
1032+
### Key Files
1033+
1034+
- `dash/dash.py` - WebSocket config in `_generate_config()`
1035+
- `dash/dash-renderer/src/utils/workerClient.ts` - Browser-side SharedWorker client
1036+
- `@plotly/dash-websocket-worker/src/WebSocketManager.ts` - WebSocket connection management
1037+
- `@plotly/dash-websocket-worker/src/worker.ts` - SharedWorker entry point
1038+
- `dash/backends/_fastapi.py` - Server-side WebSocket handler
1039+
8641040
## Security
8651041

8661042
### XSS Protection

.github/workflows/testing.yml

Lines changed: 81 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ jobs:
1919
backend_cb_changed: ${{ steps.filter.outputs.backend_paths }}
2020
dcc_paths_changed: ${{ steps.filter.outputs.dcc_related_paths }}
2121
html_paths_changed: ${{ steps.filter.outputs.html_related_paths }}
22+
websocket_changed: ${{ steps.filter.outputs.websocket_paths }}
2223
steps:
2324
- name: Checkout repository
2425
uses: actions/checkout@v4
@@ -48,6 +49,18 @@ jobs:
4849
backend_paths:
4950
- 'dash/backends/**'
5051
- 'tests/backend_tests/**'
52+
websocket_paths:
53+
- 'dash/backends/_fastapi.py'
54+
- 'dash/backends/_quart.py'
55+
- 'dash/backends/base_server.py'
56+
- 'dash/_callback.py'
57+
- 'dash/_callback_context.py'
58+
- 'dash/_hooks.py'
59+
- 'dash/dash.py'
60+
- '@dash-websocket-worker/**'
61+
- 'dash/dash-renderer/src/**'
62+
- 'tests/websocket/**'
63+
- 'requirements/**'
5164
5265
lint-unit:
5366
name: Lint & Unit Tests (Python ${{ matrix.python-version }})
@@ -366,7 +379,7 @@ jobs:
366379
- name: Set up Node.js
367380
uses: actions/setup-node@v4
368381
with:
369-
node-version: '20'
382+
node-version: '24'
370383
cache: 'npm'
371384

372385
- name: Install Node.js dependencies
@@ -377,6 +390,7 @@ jobs:
377390
with:
378391
python-version: ${{ matrix.python-version }}
379392
cache: 'pip'
393+
cache-dependency-path: requirements/*.txt
380394

381395
- name: Download built Dash packages
382396
uses: actions/download-artifact@v4
@@ -387,43 +401,13 @@ jobs:
387401
- name: Install Dash packages
388402
run: |
389403
python -m pip install --upgrade pip wheel
390-
python -m pip install "setuptools<78.0.0"
391-
python -m pip install "selenium==4.32.0"
404+
python -m pip install "setuptools<80.0.0"
392405
find packages -name dash-*.whl -print -exec sh -c 'pip install "{}[async,ci,testing,dev,celery,diskcache,fastapi,quart]"' \;
393406
394-
- name: Install Google Chrome
395-
run: |
396-
sudo apt-get update
397-
sudo apt-get install -y google-chrome-stable
398-
399-
- name: Install ChromeDriver
400-
run: |
401-
echo "Determining Chrome version..."
402-
CHROME_BROWSER_VERSION=$(google-chrome --version)
403-
echo "Installed Chrome Browser version: $CHROME_BROWSER_VERSION"
404-
CHROME_MAJOR_VERSION=$(echo "$CHROME_BROWSER_VERSION" | cut -f 3 -d ' ' | cut -f 1 -d '.')
405-
echo "Detected Chrome Major version: $CHROME_MAJOR_VERSION"
406-
if [ "$CHROME_MAJOR_VERSION" -ge 115 ]; then
407-
echo "Fetching ChromeDriver version for Chrome $CHROME_MAJOR_VERSION using CfT endpoint..."
408-
CHROMEDRIVER_VERSION_STRING=$(curl -sS "https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_${CHROME_MAJOR_VERSION}")
409-
if [ -z "$CHROMEDRIVER_VERSION_STRING" ]; then
410-
echo "Could not automatically find ChromeDriver version for Chrome $CHROME_MAJOR_VERSION via LATEST_RELEASE. Please check CfT endpoints."
411-
exit 1
412-
fi
413-
CHROMEDRIVER_URL="https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/${CHROMEDRIVER_VERSION_STRING}/linux64/chromedriver-linux64.zip"
414-
else
415-
echo "Fetching ChromeDriver version for Chrome $CHROME_MAJOR_VERSION using older method..."
416-
CHROMEDRIVER_VERSION_STRING=$(curl -sS "https://chromedriver.storage.googleapis.com/LATEST_RELEASE_${CHROME_MAJOR_VERSION}")
417-
CHROMEDRIVER_URL="https://chromedriver.storage.googleapis.com/${CHROMEDRIVER_VERSION_STRING}/chromedriver_linux64.zip"
418-
fi
419-
echo "Using ChromeDriver version string: $CHROMEDRIVER_VERSION_STRING"
420-
echo "Downloading ChromeDriver from: $CHROMEDRIVER_URL"
421-
wget -q -O chromedriver.zip "$CHROMEDRIVER_URL"
422-
unzip -o chromedriver.zip -d /tmp/
423-
sudo mv /tmp/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver || sudo mv /tmp/chromedriver /usr/local/bin/chromedriver
424-
sudo chmod +x /usr/local/bin/chromedriver
425-
echo "/usr/local/bin" >> $GITHUB_PATH
426-
shell: bash
407+
- name: Setup Chrome and ChromeDriver
408+
uses: browser-actions/setup-chrome@v1
409+
with:
410+
chrome-version: stable
427411

428412
- name: Build/Setup test components
429413
run: npm run setup-tests.py
@@ -558,6 +542,67 @@ jobs:
558542
path: components/dash-table/test-reports/
559543
retention-days: 7
560544

545+
websocket-tests:
546+
name: WebSocket Tests (Python ${{ matrix.python-version }})
547+
needs: [build, changes_filter]
548+
if: |
549+
(github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) ||
550+
needs.changes_filter.outputs.websocket_changed == 'true'
551+
timeout-minutes: 30
552+
runs-on: ubuntu-latest
553+
strategy:
554+
fail-fast: false
555+
matrix:
556+
python-version: ["3.9", "3.12"]
557+
558+
steps:
559+
- name: Checkout repository
560+
uses: actions/checkout@v4
561+
562+
- name: Set up Node.js
563+
uses: actions/setup-node@v4
564+
with:
565+
node-version: '24'
566+
cache: 'npm'
567+
568+
- name: Install Node.js dependencies
569+
run: npm ci
570+
571+
- name: Set up Python ${{ matrix.python-version }}
572+
uses: actions/setup-python@v5
573+
with:
574+
python-version: ${{ matrix.python-version }}
575+
cache: 'pip'
576+
cache-dependency-path: requirements/*.txt
577+
578+
- name: Download built Dash packages
579+
uses: actions/download-artifact@v4
580+
with:
581+
name: dash-packages
582+
path: packages/
583+
584+
- name: Install Dash packages
585+
run: |
586+
python -m pip install --upgrade pip wheel
587+
python -m pip install "setuptools<80.0.0"
588+
find packages -name dash-*.whl -print -exec sh -c 'pip install "{}[ci,testing,dev,fastapi,quart]"' \;
589+
590+
- name: Setup Chrome and ChromeDriver
591+
uses: browser-actions/setup-chrome@v1
592+
with:
593+
chrome-version: stable
594+
595+
- name: Build/Setup test components
596+
run: npm run setup-tests.py
597+
598+
- name: Run WebSocket tests
599+
run: |
600+
mkdir wstests
601+
cp -r tests wstests/tests
602+
cd wstests
603+
touch __init__.py
604+
pytest --headless --nopercyfinalize tests/websocket -v -s
605+
561606
test-main:
562607
name: Main Dash Tests (Python ${{ matrix.python-version }}, Group ${{ matrix.test-group }})
563608
needs: build
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Dash websocket worker
2+
3+
Worker for websocket based callbacks.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@plotly/dash-websocket-worker",
3+
"version": "1.0.0",
4+
"description": "SharedWorker for WebSocket-based Dash callbacks",
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"scripts": {
8+
"build": "webpack --mode production",
9+
"build:dev": "webpack --mode development",
10+
"watch": "webpack --mode development --watch",
11+
"clean": "rm -rf dist"
12+
},
13+
"files": [
14+
"dist"
15+
],
16+
"keywords": [
17+
"dash",
18+
"websocket",
19+
"sharedworker"
20+
],
21+
"author": "Plotly",
22+
"license": "MIT",
23+
"devDependencies": {
24+
"typescript": "^5.0.0",
25+
"webpack": "^5.0.0",
26+
"webpack-cli": "^5.0.0",
27+
"ts-loader": "^9.0.0"
28+
}
29+
}

0 commit comments

Comments
 (0)