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:
poduck
2025-12-01 01:40:45 -05:00
parent be3b5b2d08
commit a274d70cec
7 changed files with 103 additions and 9 deletions

View File

@@ -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);

View File

@@ -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

View File

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

View File

@@ -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...
# ------------------------------------------------------------------------------

View File

@@ -33,6 +33,7 @@ SHARED_APPS = [
'rest_framework.authtoken',
'corsheaders',
'drf_spectacular',
'channels', # WebSockets
'allauth',
'allauth.account',
'allauth.mfa',

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

View File

@@ -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()),
]