Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2148fbb
Add websocket callbacks
T4rk1n Apr 14, 2026
ad3693e
websocket flag for individual callbacks
T4rk1n Apr 14, 2026
1a20893
ensure connected for ws callbacks
T4rk1n Apr 14, 2026
aff6dee
websocket origin validation
T4rk1n Apr 14, 2026
a4f10fd
add close method to DashWebsocket
T4rk1n Apr 14, 2026
206c9d8
add websocket hooks
T4rk1n Apr 14, 2026
fed2311
Add websocket inactivity disconnect
T4rk1n Apr 16, 2026
7d01f53
no storing of rendererId in session storage
T4rk1n Apr 16, 2026
b91c5d5
rename allowed_websocket_origins -> websocket_allowed_origins
T4rk1n Apr 16, 2026
96824a9
update architecture docs with websocket details
T4rk1n Apr 17, 2026
290ba68
Merge branch 'v4.2' into websocket-callbacks
T4rk1n Apr 23, 2026
3a14ef3
add websocket callback validation and tests
T4rk1n Apr 23, 2026
2ba5557
fixes
T4rk1n Apr 23, 2026
104a71d
add quart websocket callback implementation
T4rk1n Apr 24, 2026
2070708
fix tests
T4rk1n Apr 24, 2026
750a585
remove dev bundle reference
T4rk1n Apr 24, 2026
deda670
version 4.2.0rc1
T4rk1n Apr 24, 2026
e4849eb
Fix websocket callback set_props() with Patch object problems
CNFeffery Apr 26, 2026
70cbf48
Add websocket callback set_props patch tests
CNFeffery Apr 26, 2026
68a696a
Update CHANGELOG
CNFeffery Apr 26, 2026
65f3dc2
Format code
CNFeffery Apr 26, 2026
6460335
Fix websocket callback update component prop via set_props()
CNFeffery Apr 26, 2026
4b19d9a
Add websocket callback update component prop tests
CNFeffery Apr 26, 2026
411b467
Add websocket callback update component prop tests
CNFeffery Apr 26, 2026
1abd7bf
Update CHANGELOG
CNFeffery Apr 27, 2026
96af07d
Enhance websocket set_props with plotly JSON for full prop type compa…
CNFeffery Apr 28, 2026
649eadb
Fix format
CNFeffery Apr 28, 2026
c7b3f74
Optimize websocket set_props by centralizing serialization with _send…
CNFeffery Apr 28, 2026
a8fbd57
Merge pull request #3759 from CNFeffery/websocket-callbacks
T4rk1n Apr 30, 2026
fe5489d
Merge branch 'v4.2' into websocket-callbacks
T4rk1n Apr 30, 2026
8b86163
threadpool for ws callback execution
T4rk1n May 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions .ai/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,11 @@ Special handling for Colab:
- `background_callback_manager` - DiskcacheManager or CeleryManager
- `on_error` - Global callback error handler

**WebSocket Callbacks:**
- `websocket_callbacks` - Enable WebSocket for all callbacks (default: `False`). Requires FastAPI backend.
- `websocket_allowed_origins` - List of allowed origins for WebSocket connections
- `websocket_inactivity_timeout` - Disconnect WebSocket after inactivity period in ms (default: `300000` = 5 minutes). Set to `0` to disable.

### app.run() Parameters

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

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

## WebSocket Callbacks

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.

### Requirements

- **FastAPI backend required**: WebSocket callbacks only work with FastAPI
- **SharedWorker support**: Modern browsers (not IE)

### Usage

**Enable globally for all callbacks:**
```python
from fastapi import FastAPI
from dash import Dash

server = FastAPI()
app = Dash(__name__, server=server, websocket_callbacks=True)
```

**Enable per-callback:**
```python
@app.callback(
Output('output', 'children'),
Input('input', 'value'),
websocket=True # Use WebSocket for this callback only
)
def update(value):
return f"Value: {value}"
```

### Configuration

```python
app = Dash(
__name__,
server=server,
websocket_callbacks=True,
websocket_inactivity_timeout=300000, # 5 minutes (default)
websocket_allowed_origins=['https://example.com'],
)
```

- **`websocket_callbacks`** - Enable WebSocket for all callbacks (default: `False`)
- **`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.
- **`websocket_allowed_origins`** - List of allowed origins for WebSocket connections (security)

### Architecture

```
┌─────────────────────────────────────────────────────────────────────────┐
│ Browser Tab 1 Browser Tab 2 │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Renderer │ │ Renderer │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ postMessage │ postMessage │
│ └────────────┬───────────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ SharedWorker │ (one per origin) │
│ │ dash-ws-worker │ │
│ └──────────┬──────────┘ │
└────────────────────│────────────────────────────────────────────────────┘
│ WebSocket
┌─────────────────────────────────────────────────────────────────────────┐
│ Server (FastAPI) │
│ WebSocket Endpoint: /_dash-ws-callback │
└─────────────────────────────────────────────────────────────────────────┘
```

**Connection & Reconnection Flow:**
```
Renderer SharedWorker Server
│ │ │
│──[CONNECT]──────────────────>│ │
│ │──[WebSocket Connect]──>│
│<─[CONNECTED]─────────────────│<─[Connected]───────────│
│ │ │
│──[CALLBACK_REQUEST]─────────>│──[callback request]───>│
│<─[CALLBACK_RESPONSE]─────────│<─[callback response]───│
│ │ │
│ (inactivity) │ (heartbeat check) │
│ │──[close 4001]─────────>│
│<─[DISCONNECTED]──────────────│ │
│ │ │
│──[CALLBACK_REQUEST]─────────>│──[reconnect + send]───>│
│<─[CALLBACK_RESPONSE]─────────│<─[response]────────────│
```

- **SharedWorker**: Single WebSocket connection shared across browser tabs
- **Heartbeat**: Periodic ping/pong to detect dead connections (30s interval)
- **Inactivity timeout**: Closes connection after no actual callback activity (not heartbeats)
- **Auto-reconnect**: Reconnects automatically when a callback is triggered after timeout

### Long-Running Callbacks with set_props/get_props

WebSocket callbacks can stream updates to the client during execution using `set_props()` and read current component values using `ctx.get_websocket()`:

```python
import asyncio
from dash import callback, Output, Input, set_props, ctx

@callback(
Output('result', 'children'),
Input('start-btn', 'n_clicks'),
prevent_initial_call=True
)
async def long_running_task(n_clicks):
ws = ctx.get_websocket()
if not ws:
return "WebSocket not available"

# Stream progress updates to the client
for i in range(100):
await asyncio.sleep(0.1)
set_props('progress-bar', {'value': i + 1})
set_props('status', {'children': f'Processing step {i + 1}/100...'})

# Read current value from another component
current_value = await ws.get_prop('input-field', 'value')

return f"Completed! Input was: {current_value}"
```

**API:**
- `set_props(component_id, props_dict)` - Stream prop updates immediately to client
- `ctx.get_websocket()` - Get WebSocket interface (returns `None` if not in WS context)
- `await ws.get_prop(component_id, prop_name)` - Read current prop value from client
- `await ws.set_prop(component_id, prop_name, value)` - Set single prop (async version)
- `await ws.close(code, reason)` - Close the WebSocket connection

### Connection Hooks

Use hooks to validate connections and messages:

```python
from dash import Dash, hooks

@hooks.websocket_connect()
async def validate_connection(websocket):
"""Validate WebSocket connection before accepting."""
session_id = websocket.cookies.get("session_id")
if not session_id:
return (4001, "No session cookie")
if not await is_valid_session(session_id):
return (4002, "Invalid session")
return True # Allow connection

@hooks.websocket_message()
async def validate_message(websocket, message):
"""Validate each WebSocket message."""
session_id = websocket.cookies.get("session_id")
if not await is_session_active(session_id):
return (4002, "Session expired")
return True # Allow message
```

**Hook Return Values:**
- `True` (or truthy) - Allow connection/message
- `False` - Reject with default code (4001)
- `(code, reason)` - Reject with custom close code and reason

### Key Files

- `dash/dash.py` - WebSocket config in `_generate_config()`
- `dash/dash-renderer/src/utils/workerClient.ts` - Browser-side SharedWorker client
- `@plotly/dash-websocket-worker/src/WebSocketManager.ts` - WebSocket connection management
- `@plotly/dash-websocket-worker/src/worker.ts` - SharedWorker entry point
- `dash/backends/_fastapi.py` - Server-side WebSocket handler

## Security

### XSS Protection
Expand Down
117 changes: 81 additions & 36 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
backend_cb_changed: ${{ steps.filter.outputs.backend_paths }}
dcc_paths_changed: ${{ steps.filter.outputs.dcc_related_paths }}
html_paths_changed: ${{ steps.filter.outputs.html_related_paths }}
websocket_changed: ${{ steps.filter.outputs.websocket_paths }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand Down Expand Up @@ -48,6 +49,18 @@ jobs:
backend_paths:
- 'dash/backends/**'
- 'tests/backend_tests/**'
websocket_paths:
- 'dash/backends/_fastapi.py'
- 'dash/backends/_quart.py'
- 'dash/backends/base_server.py'
- 'dash/_callback.py'
- 'dash/_callback_context.py'
- 'dash/_hooks.py'
- 'dash/dash.py'
- '@dash-websocket-worker/**'
- 'dash/dash-renderer/src/**'
- 'tests/websocket/**'
- 'requirements/**'

lint-unit:
name: Lint & Unit Tests (Python ${{ matrix.python-version }})
Expand Down Expand Up @@ -366,7 +379,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '24'
cache: 'npm'

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

- name: Download built Dash packages
uses: actions/download-artifact@v4
Expand All @@ -387,43 +401,13 @@ jobs:
- name: Install Dash packages
run: |
python -m pip install --upgrade pip wheel
python -m pip install "setuptools<78.0.0"
python -m pip install "selenium==4.32.0"
python -m pip install "setuptools<80.0.0"
find packages -name dash-*.whl -print -exec sh -c 'pip install "{}[async,ci,testing,dev,celery,diskcache,fastapi,quart]"' \;

- name: Install Google Chrome
run: |
sudo apt-get update
sudo apt-get install -y google-chrome-stable

- name: Install ChromeDriver
run: |
echo "Determining Chrome version..."
CHROME_BROWSER_VERSION=$(google-chrome --version)
echo "Installed Chrome Browser version: $CHROME_BROWSER_VERSION"
CHROME_MAJOR_VERSION=$(echo "$CHROME_BROWSER_VERSION" | cut -f 3 -d ' ' | cut -f 1 -d '.')
echo "Detected Chrome Major version: $CHROME_MAJOR_VERSION"
if [ "$CHROME_MAJOR_VERSION" -ge 115 ]; then
echo "Fetching ChromeDriver version for Chrome $CHROME_MAJOR_VERSION using CfT endpoint..."
CHROMEDRIVER_VERSION_STRING=$(curl -sS "https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_${CHROME_MAJOR_VERSION}")
if [ -z "$CHROMEDRIVER_VERSION_STRING" ]; then
echo "Could not automatically find ChromeDriver version for Chrome $CHROME_MAJOR_VERSION via LATEST_RELEASE. Please check CfT endpoints."
exit 1
fi
CHROMEDRIVER_URL="https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/${CHROMEDRIVER_VERSION_STRING}/linux64/chromedriver-linux64.zip"
else
echo "Fetching ChromeDriver version for Chrome $CHROME_MAJOR_VERSION using older method..."
CHROMEDRIVER_VERSION_STRING=$(curl -sS "https://chromedriver.storage.googleapis.com/LATEST_RELEASE_${CHROME_MAJOR_VERSION}")
CHROMEDRIVER_URL="https://chromedriver.storage.googleapis.com/${CHROMEDRIVER_VERSION_STRING}/chromedriver_linux64.zip"
fi
echo "Using ChromeDriver version string: $CHROMEDRIVER_VERSION_STRING"
echo "Downloading ChromeDriver from: $CHROMEDRIVER_URL"
wget -q -O chromedriver.zip "$CHROMEDRIVER_URL"
unzip -o chromedriver.zip -d /tmp/
sudo mv /tmp/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver || sudo mv /tmp/chromedriver /usr/local/bin/chromedriver
sudo chmod +x /usr/local/bin/chromedriver
echo "/usr/local/bin" >> $GITHUB_PATH
shell: bash
- name: Setup Chrome and ChromeDriver
uses: browser-actions/setup-chrome@v1
with:
chrome-version: stable

- name: Build/Setup test components
run: npm run setup-tests.py
Expand Down Expand Up @@ -558,6 +542,67 @@ jobs:
path: components/dash-table/test-reports/
retention-days: 7

websocket-tests:
name: WebSocket Tests (Python ${{ matrix.python-version }})
needs: [build, changes_filter]
if: |
(github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) ||
needs.changes_filter.outputs.websocket_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.12"]

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'

- name: Install Node.js dependencies
run: npm ci

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: requirements/*.txt

- name: Download built Dash packages
uses: actions/download-artifact@v4
with:
name: dash-packages
path: packages/

- name: Install Dash packages
run: |
python -m pip install --upgrade pip wheel
python -m pip install "setuptools<80.0.0"
find packages -name dash-*.whl -print -exec sh -c 'pip install "{}[ci,testing,dev,fastapi,quart]"' \;

- name: Setup Chrome and ChromeDriver
uses: browser-actions/setup-chrome@v1
with:
chrome-version: stable

- name: Build/Setup test components
run: npm run setup-tests.py

- name: Run WebSocket tests
run: |
mkdir wstests
cp -r tests wstests/tests
cd wstests
touch __init__.py
pytest --headless --nopercyfinalize tests/websocket -v -s

test-main:
name: Main Dash Tests (Python ${{ matrix.python-version }}, Group ${{ matrix.test-group }})
needs: build
Expand Down
3 changes: 3 additions & 0 deletions @plotly/dash-websocket-worker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Dash websocket worker

Worker for websocket based callbacks.
29 changes: 29 additions & 0 deletions @plotly/dash-websocket-worker/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@plotly/dash-websocket-worker",
"version": "1.0.0",
"description": "SharedWorker for WebSocket-based Dash callbacks",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "webpack --mode production",
"build:dev": "webpack --mode development",
"watch": "webpack --mode development --watch",
"clean": "rm -rf dist"
},
"files": [
"dist"
],
"keywords": [
"dash",
"websocket",
"sharedworker"
],
"author": "Plotly",
"license": "MIT",
"devDependencies": {
"typescript": "^5.0.0",
"webpack": "^5.0.0",
"webpack-cli": "^5.0.0",
"ts-loader": "^9.0.0"
}
}
Loading
Loading