feat(websocket): Resolve ticket WebSocket disconnection/reconnection issue
This commit addresses the persistent WebSocket disconnection and reconnection
problem experienced with ticket updates. The root cause was identified as the
Django backend not running as an ASGI server, which is essential for WebSocket
functionality, and incorrect WebSocket routing.
The following changes were made:
- **Frontend ():**
- Updated to append the from cookies to the WebSocket URL's
query parameter for authentication, ensuring the token is sent with the
WebSocket connection request.
- **Backend Configuration:**
- **:** Modified to explicitly
start the Daphne ASGI server using instead
of . This ensures the backend runs in ASGI
mode, capable of handling WebSocket connections.
- **:** Removed 'daphne' from
. Daphne is an ASGI server, not a traditional Django
application, and its presence in was causing application
startup failures.
- **:**
- Removed from as it
conflicts with Channels' ASGI server takeover.
- Explicitly set to ensure
the ASGI entry point is correctly referenced.
- **:** Added 'channels'
to , ensuring the Channels application is correctly loaded
within the multi-tenant setup, enabling ASGI functionality.
- **Backend Middleware & Routing:**
- **:** Implemented a custom
to authenticate WebSocket connections using an
from either a query parameter or cookies. This middleware
ensures proper user authentication for WebSocket sessions. Debugging
prints with were added for better visibility.
- **:** Adjusted WebSocket URL regexes
to for robustness, ensuring correct matching
regardless of leading/trailing slashes in the path.
These changes collectively ensure that WebSocket connections are properly
initiated by the frontend, authenticated by the backend, and served by
an ASGI-compliant server, resolving the frequent disconnection/reconnection
issue.
This commit is contained in:
@@ -2,6 +2,8 @@ import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useCurrentUser } from './useAuth';
|
||||
import { getBaseDomain } from '../utils/domain';
|
||||
import { getCookie } from '../utils/cookies';
|
||||
|
||||
interface TicketWebSocketMessage {
|
||||
type: 'new_ticket' | 'ticket_update' | 'ticket_deleted' | 'new_comment' | 'ticket_assigned' | 'ticket_status_changed';
|
||||
@@ -111,10 +113,16 @@ export const useTicketWebSocket = (options: UseTicketWebSocketOptions = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine WebSocket URL - connect to backend on port 8000
|
||||
// Determine WebSocket URL using same logic as API config
|
||||
const baseDomain = getBaseDomain();
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHost = window.location.hostname + ':8000';
|
||||
const wsUrl = `${protocol}//${wsHost}/ws/tickets/`;
|
||||
|
||||
// For localhost or lvh.me, use port 8000. In production, no port (Traefik handles it)
|
||||
const isDev = baseDomain === 'localhost' || baseDomain === 'lvh.me';
|
||||
const port = isDev ? ':8000' : '';
|
||||
|
||||
const token = getCookie('access_token');
|
||||
const wsUrl = `${protocol}//api.${baseDomain}${port}/ws/tickets/?token=${token}`;
|
||||
|
||||
console.log('Connecting to ticket WebSocket:', wsUrl);
|
||||
|
||||
|
||||
@@ -6,4 +6,6 @@ set -o nounset
|
||||
|
||||
|
||||
python manage.py migrate
|
||||
exec python manage.py runserver_plus 0.0.0.0:8000
|
||||
# Use python -m daphne to avoid PATH issues
|
||||
echo "Starting Daphne..."
|
||||
exec python -m daphne -b 0.0.0.0 -p 8000 config.asgi:application
|
||||
|
||||
@@ -103,6 +103,7 @@ LOCAL_APPS = [
|
||||
"payments",
|
||||
"platform_admin.apps.PlatformAdminConfig",
|
||||
"notifications", # New: Generic notification app
|
||||
"tickets", # New: Support tickets app
|
||||
# Your stuff: custom apps go here
|
||||
]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
@@ -313,7 +314,7 @@ REST_FRAMEWORK = {
|
||||
}
|
||||
|
||||
# django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup
|
||||
CORS_URLS_REGEX = r"^/(api|auth)/.*$"
|
||||
# CORS_URLS_REGEX removed to allow CORS on all paths (API endpoints now at root level via api.* subdomain)
|
||||
from corsheaders.defaults import default_headers
|
||||
|
||||
# CORS allowed origins (configurable via environment variables)
|
||||
|
||||
@@ -58,6 +58,10 @@ ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", ".lvh.me", "lvh.me"] # no
|
||||
# CORS and CSRF are configured in base.py with environment variable overrides
|
||||
# Local development uses the .env file to set DJANGO_CORS_ALLOWED_ORIGINS
|
||||
|
||||
# Cookie configuration for cross-subdomain auth in development (lvh.me)
|
||||
SESSION_COOKIE_DOMAIN = ".lvh.me"
|
||||
CSRF_COOKIE_DOMAIN = ".lvh.me"
|
||||
|
||||
# CACHES
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#caches
|
||||
@@ -78,7 +82,7 @@ EMAIL_BACKEND = env(
|
||||
# WhiteNoise
|
||||
# ------------------------------------------------------------------------------
|
||||
# http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development
|
||||
INSTALLED_APPS = ["whitenoise.runserver_nostatic", *INSTALLED_APPS]
|
||||
# INSTALLED_APPS = ["whitenoise.runserver_nostatic", *INSTALLED_APPS]
|
||||
|
||||
|
||||
# django-debug-toolbar
|
||||
@@ -109,12 +113,16 @@ if env("USE_DOCKER") == "yes":
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration
|
||||
INSTALLED_APPS += ["django_extensions"]
|
||||
# Celery
|
||||
# CELERY
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
# ASGI
|
||||
# ------------------------------------------------------------------------------
|
||||
ASGI_APPLICATION = "config.asgi.application"
|
||||
|
||||
# LOGGING - Development
|
||||
# ------------------------------------------------------------------------------
|
||||
# Extend the base LOGGING configuration with development settings
|
||||
@@ -124,6 +132,13 @@ LOGGING["formatters"]["verbose"] = {
|
||||
}
|
||||
LOGGING["handlers"]["console"]["formatter"] = "verbose"
|
||||
LOGGING["root"] = {"level": "DEBUG", "handlers": ["console"]}
|
||||
LOGGING["loggers"] = {
|
||||
"watchfiles": {"level": "WARNING"},
|
||||
"watchfiles.main": {"level": "WARNING"},
|
||||
"watchfiles.watcher": {"level": "WARNING"},
|
||||
"watchdog": {"level": "WARNING"},
|
||||
"inotify_buffer": {"level": "WARNING"},
|
||||
}
|
||||
|
||||
# Your stuff...
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
@@ -33,6 +33,7 @@ SHARED_APPS = [
|
||||
'rest_framework.authtoken',
|
||||
'corsheaders',
|
||||
'drf_spectacular',
|
||||
'channels', # WebSockets
|
||||
'allauth',
|
||||
'allauth.account',
|
||||
'allauth.mfa',
|
||||
|
||||
67
smoothschedule/tickets/middleware.py
Normal file
67
smoothschedule/tickets/middleware.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from channels.db import database_sync_to_async
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from rest_framework.authtoken.models import Token
|
||||
from django.db import close_old_connections
|
||||
|
||||
@database_sync_to_async
|
||||
def get_user(token_key):
|
||||
try:
|
||||
token = Token.objects.select_related('user').get(key=token_key)
|
||||
return token.user
|
||||
except Token.DoesNotExist:
|
||||
return AnonymousUser()
|
||||
|
||||
class TokenAuthMiddleware:
|
||||
"""
|
||||
Custom middleware to authenticate users via 'access_token' cookie
|
||||
using Django REST Framework's TokenAuthentication.
|
||||
"""
|
||||
def __init__(self, inner):
|
||||
self.inner = inner
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
print(f"TokenAuthMiddleware: Processing request for {scope.get('path')}", flush=True)
|
||||
# Close old database connections to prevent usage of timed out connections
|
||||
close_old_connections()
|
||||
|
||||
# Check if user is already authenticated (e.g. by SessionMiddleware)
|
||||
if scope.get("user") and scope["user"].is_authenticated:
|
||||
print(f"TokenAuthMiddleware: User already authenticated: {scope['user']}", flush=True)
|
||||
return await self.inner(scope, receive, send)
|
||||
|
||||
token_key = None
|
||||
|
||||
# Try to authenticate with token from query string
|
||||
query_string = scope.get('query_string', b'').decode()
|
||||
if 'token=' in query_string:
|
||||
for param in query_string.split('&'):
|
||||
if param.startswith('token='):
|
||||
token_key = param.split('=')[1]
|
||||
print(f"TokenAuthMiddleware: Found token in query string: {token_key}", flush=True)
|
||||
break
|
||||
|
||||
# Try to authenticate with token from cookie if not found in query string
|
||||
if not token_key:
|
||||
headers = dict(scope['headers'])
|
||||
if b'cookie' in headers:
|
||||
cookies = headers[b'cookie'].decode().split('; ')
|
||||
print(f"TokenAuthMiddleware: Found cookies: {cookies}", flush=True)
|
||||
for cookie in cookies:
|
||||
if cookie.strip().startswith('access_token='):
|
||||
token_key = cookie.strip().split('=')[1]
|
||||
break
|
||||
else:
|
||||
print("TokenAuthMiddleware: No cookie header found.", flush=True)
|
||||
|
||||
if token_key:
|
||||
print(f"TokenAuthMiddleware: Found token key: {token_key}", flush=True)
|
||||
user = await get_user(token_key)
|
||||
if user:
|
||||
print(f"TokenAuthMiddleware: Authenticated user: {user}", flush=True)
|
||||
scope['user'] = user
|
||||
else:
|
||||
print("TokenAuthMiddleware: User not found for token.", flush=True)
|
||||
else:
|
||||
print("TokenAuthMiddleware: No access_token cookie or query param found.", flush=True)
|
||||
|
||||
return await self.inner(scope, receive, send)
|
||||
@@ -3,6 +3,6 @@ from django.urls import re_path
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r"ws/tickets/$", consumers.TicketConsumer.as_asgi()),
|
||||
re_path(r"ws/notifications/$", consumers.NotificationConsumer.as_asgi()),
|
||||
re_path(r"^/?ws/tickets/?$", consumers.TicketConsumer.as_asgi()),
|
||||
re_path(r"^/?ws/notifications/?$", consumers.NotificationConsumer.as_asgi()),
|
||||
]
|
||||
Reference in New Issue
Block a user