Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
115 changes: 79 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,16 @@ jobs:
backend_paths:
- 'dash/backends/**'
- 'tests/backend_tests/**'
websocket_paths:
- 'dash/backends/_fastapi.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 +377,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 +388,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 +399,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 +540,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]"' \;

- 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