run socket.io on the same port as main app

This commit is contained in:
dgtlmoon
2025-05-09 18:43:52 +02:00
parent 5801e46d53
commit c0c5b1d2df
5 changed files with 123 additions and 143 deletions

View File

@@ -16,7 +16,7 @@ import eventlet.wsgi
import getopt
import platform
import signal
import socket
import socket # Make sure socket is imported at the module level
import sys
from flask import request
@@ -148,22 +148,16 @@ def main():
# Get the Flask app
app = changedetection_app(app_config, datastore)
# Now initialize Socket.IO after the app is fully set up
# Initialize Socket.IO integrated with the main Flask app
try:
from changedetectionio.realtime.socket_server import ChangeDetectionSocketIO
from changedetectionio.flask_app import socketio_server
import threading
from changedetectionio.realtime.socket_server import init_socketio
# Create the Socket.IO server
socketio_server = ChangeDetectionSocketIO(app, datastore)
# Run the Socket.IO server in a separate thread on port 5005
socket_thread = threading.Thread(target=socketio_server.run,
kwargs={'host': host, 'port': 5005})
socket_thread.daemon = True
socket_thread.start()
logger.info("Socket.IO server initialized successfully on port 5005")
# Initialize Socket.IO with the main Flask app
# This will be used later when we run the app with socketio.run()
socketio = init_socketio(app, datastore)
app.config['SOCKETIO'] = socketio
logger.info("Socket.IO server initialized successfully (integrated with main app)")
except Exception as e:
logger.warning(f"Failed to initialize Socket.IO server: {str(e)}")
@@ -192,23 +186,12 @@ def main():
@app.context_processor
def inject_version():
# Get server host and port for Socket.IO
socket_host = host if host else '127.0.0.1'
socket_port = 5005 # Fixed port for Socket.IO server
# Create Socket.IO URL (use host from proxy if available)
socketio_url = None
if os.getenv('USE_X_SETTINGS') and 'X-Forwarded-Host' in request.headers:
# When behind a proxy, use the forwarded host but maintain the Socket.IO port
socketio_url = f"http://{request.headers['X-Forwarded-Host'].split(':')[0]}:{socket_port}"
else:
# Direct connection
socketio_url = f"http://{socket_host}:{socket_port}"
# Socket.IO is now integrated with the main app
# The client will automatically connect to the Socket.IO endpoint on the same host/port
return dict(right_sticky="v{}".format(datastore.data['version_tag']),
new_version_available=app.config['NEW_VERSION_AVAILABLE'],
has_password=datastore.data['settings']['application']['password'] != False,
socketio_url=socketio_url
has_password=datastore.data['settings']['application']['password'] != False
)
# Monitored websites will not receive a Referer header when a user clicks on an outgoing link.
@@ -242,7 +225,20 @@ def main():
server_side=True), app)
else:
# We'll integrate the Socket.IO server with the WSGI server
# The Socket.IO server is already attached to the Flask app
eventlet.wsgi.server(eventlet.listen((host, int(port)), s_type), app)
# Run the app with Socket.IO's integrated server
if 'SOCKETIO' in app.config:
# When using eventlet or threading, we need to make sure the host is valid
# Use '0.0.0.0' for all interfaces if host is empty or invalid
try:
socket_host = host if host else '0.0.0.0'
logger.info(f"Starting integrated Socket.IO server on http://{socket_host}:{port}")
app.config['SOCKETIO'].run(app, host=socket_host, port=int(port), debug=False, use_reloader=False, allow_unsafe_werkzeug=True)
except socket.gaierror:
# If the hostname is invalid, fall back to '0.0.0.0'
logger.warning(f"Invalid hostname '{host}', falling back to '0.0.0.0'")
app.config['SOCKETIO'].run(app, host='0.0.0.0', port=int(port), debug=False, use_reloader=False, allow_unsafe_werkzeug=True)
else:
# Fallback to eventlet if Socket.IO initialization failed
logger.info(f"Starting standard Flask server on http://{host}:{port}")
eventlet.wsgi.server(eventlet.listen((host, int(port)), s_type), app)

View File

@@ -126,21 +126,16 @@ def get_css_version():
return __version__
@app.template_global()
def get_socketio_url():
"""Generate the correct Socket.IO URL based on the current request"""
socket_port = 5005 # Fixed port for Socket.IO server
def get_socketio_path():
"""Generate the correct Socket.IO path prefix for the client"""
# If behind a proxy with a sub-path, we need to respect that path
prefix = ""
if os.getenv('USE_X_SETTINGS') and 'X-Forwarded-Prefix' in request.headers:
prefix = request.headers['X-Forwarded-Prefix']
# Socket.IO will be available at {prefix}/socket.io/
return prefix
if os.getenv('USE_X_SETTINGS') and request.headers.get('X-Forwarded-Host'):
# When behind a proxy, use the forwarded host but maintain the Socket.IO port
forwarded_host = request.headers['X-Forwarded-Host'].split(':')[0]
return f"http://{forwarded_host}:{socket_port}"
elif request.host:
# Use the current host but with the Socket.IO port
client_host = request.host.split(':')[0]
return f"http://{client_host}:{socket_port}"
else:
# Fallback to default
return f"http://127.0.0.1:{socket_port}"
@app.template_filter('format_number_locale')
def _jinja2_filter_format_number_locale(value: float) -> str:

View File

@@ -12,119 +12,102 @@ from changedetectionio.flask_app import _jinja2_filter_datetime, watch_check_com
class SignalHandler:
"""A standalone class to receive signals"""
def __init__(self, socketio_instance):
def __init__(self, socketio_instance, datastore):
self.socketio_instance = socketio_instance
# Get signal from app config
app_signal = socketio_instance.main_app.config.get('WATCH_CHECK_COMPLETED_SIGNAL')
if app_signal:
app_signal.connect(self.handle_signal, weak=False)
logger.info("SignalHandler: Connected to signal from app config")
else:
# Fallback if not in app config
from changedetectionio.flask_app import watch_check_completed as wcc
wcc.connect(self.handle_signal, weak=False)
logger.info("SignalHandler: Connected to signal from direct import")
self.datastore = datastore
# Connect to the watch_check_completed signal
from changedetectionio.flask_app import watch_check_completed as wcc
wcc.connect(self.handle_signal, weak=False)
logger.info("SignalHandler: Connected to signal from direct import")
def handle_signal(self, *args, **kwargs):
logger.info(f"SignalHandler: Signal received with {len(args)} args and {len(kwargs)} kwargs")
# Safely extract the watch UUID from kwargs
watch_uuid = kwargs.get('watch_uuid')
if watch_uuid:
# Get the datastore from the socket instance
datastore = self.socketio_instance.datastore
# Get the watch object from the datastore
watch = datastore.data['watching'].get(watch_uuid)
watch = self.datastore.data['watching'].get(watch_uuid)
if watch:
# Forward to the socket instance with the watch parameter
self.socketio_instance.handle_watch_update(watch=watch)
# Forward to handle_watch_update with the watch parameter
handle_watch_update(self.socketio_instance, watch=watch, datastore=self.datastore)
logger.info(f"Signal handler processed watch UUID {watch_uuid}")
else:
logger.warning(f"Watch UUID {watch_uuid} not found in datastore")
class ChangeDetectionSocketIO:
def __init__(self, app, datastore):
self.main_app = app
self.datastore = datastore
# Use threading mode instead of eventlet
self.socketio = SocketIO(self.main_app,
async_mode='threading',
cors_allowed_origins="*",
logger=False,
engineio_logger=False)
# Set up event handlers
self.socketio.on_event('connect', self.handle_connect)
self.socketio.on_event('disconnect', self.handle_disconnect)
# Don't patch the update_watch method - this was causing issues
# Just start a background thread to periodically emit watch status
self.thread = None
self.thread_lock = threading.Lock()
# Create a dedicated signal handler
self.signal_handler = SignalHandler(self)
def handle_watch_update(socketio, **kwargs):
"""Handle watch update signal from blinker"""
try:
watch = kwargs.get('watch')
datastore = kwargs.get('datastore')
def handle_connect(self):
# Emit the watch update to all connected clients
from changedetectionio.flask_app import running_update_threads, update_q
# Get list of watches that are currently running
running_uuids = []
for t in running_update_threads:
if hasattr(t, 'current_uuid') and t.current_uuid:
running_uuids.append(t.current_uuid)
# Get list of watches in the queue
queue_list = []
for q_item in update_q.queue:
if hasattr(q_item, 'item') and 'uuid' in q_item.item:
queue_list.append(q_item.item['uuid'])
# Create a simplified watch data object to send to clients
watch_data = {
'checking_now': True if watch.get('uuid') in running_uuids else False,
'fetch_time': watch.get('fetch_time'),
'has_error': watch.get('last_error') or watch.get('last_notification_error'),
'last_changed': watch.get('last_changed'),
'last_checked': watch.get('last_checked'),
'last_checked_text': _jinja2_filter_datetime(watch),
'last_changed_text': timeago.format(int(watch['last_changed']), time.time()) if watch.history_n >=2 and int(watch.get('last_changed',0)) >0 else 'Not yet',
'queued': True if watch.get('uuid') in queue_list else False,
'paused': True if watch.get('paused') else False,
'notification_muted': True if watch.get('notification_muted') else False,
'unviewed': watch.has_unviewed,
'uuid': watch.get('uuid'),
}
socketio.emit("watch_update", watch_data)
logger.debug(f"Socket.IO: Emitted update for watch {watch.get('uuid')}")
except Exception as e:
logger.error(f"Socket.IO error in handle_watch_update: {str(e)}")
def init_socketio(app, datastore):
"""Initialize SocketIO with the main Flask app"""
# Use threading mode only - eventlet monkey patching causes issues
# when patching after other modules have been imported
async_mode = 'threading'
logger.info("Using threading mode for Socket.IO (long-polling transport)")
socketio = SocketIO(app,
async_mode=async_mode, # Use eventlet if available, otherwise threading
cors_allowed_origins="*",
logger=True,
engineio_logger=True)
# Set up event handlers
@socketio.on('connect')
def handle_connect():
"""Handle client connection"""
logger.info("Socket.IO: Client connected")
def handle_disconnect(self):
@socketio.on('disconnect')
def handle_disconnect():
"""Handle client disconnection"""
logger.info("Socket.IO: Client disconnected")
def handle_watch_update(self, **kwargs):
"""Handle watch update signal from blinker"""
try:
watch = kwargs.get('watch')
# Emit the watch update to all connected clients
with self.main_app.app_context():
from changedetectionio.flask_app import running_update_threads, update_q
# Get list of watches that are currently running
running_uuids = []
for t in running_update_threads:
if hasattr(t, 'current_uuid') and t.current_uuid:
running_uuids.append(t.current_uuid)
# Create a dedicated signal handler that will receive signals and emit them to clients
signal_handler = SignalHandler(socketio, datastore)
# Get list of watches in the queue
queue_list = []
for q_item in update_q.queue:
if hasattr(q_item, 'item') and 'uuid' in q_item.item:
queue_list.append(q_item.item['uuid'])
# Create a simplified watch data object to send to clients
watch_data = {
'checking_now': True if watch.get('uuid') in running_uuids else False,
'fetch_time': watch.get('fetch_time'),
'has_error': watch.get('last_error') or watch.get('last_notification_error'),
'last_changed': watch.get('last_changed'),
'last_checked': watch.get('last_checked'),
'last_checked_text': _jinja2_filter_datetime(watch),
'last_changed_text': timeago.format(int(watch['last_changed']), time.time()) if watch.history_n >=2 and int(watch.get('last_changed',0)) >0 else 'Not yet',
'queued': True if watch.get('uuid') in queue_list else False,
'paused': True if watch.get('paused') else False,
'notification_muted': True if watch.get('notification_muted') else False,
'unviewed': watch.has_unviewed,
'uuid': watch.get('uuid'),
}
self.socketio.emit("watch_update", watch_data)
logger.debug(f"Socket.IO: Emitted update for watch {watch.get('uuid')}")
except Exception as e:
logger.error(f"Socket.IO error in handle_watch_update: {str(e)}")
def run(self, host='0.0.0.0', port=5005):
"""Run the Socket.IO server on a separate port"""
# Start the background task when the server starts
#self.start_background_task()
# Run the Socket.IO server
# Use 0.0.0.0 to listen on all interfaces
logger.info(f"Starting Socket.IO server on http://{host}:{port}")
self.socketio.run(self.main_app, host=host, port=port, debug=False, use_reloader=False, allow_unsafe_werkzeug=True)
# Store the datastore reference on the socketio object for later use
socketio.datastore = datastore
logger.info("Socket.IO initialized and attached to main Flask app")
return socketio

View File

@@ -1,4 +1,5 @@
// Socket.IO client-side integration for changedetection.io
// @todo only bind ajax if the socket server attached success.
$(document).ready(function () {
$('.ajax-op').click(function (e) {
@@ -20,8 +21,13 @@ $(document).ready(function () {
// Try to create the socket connection to the SocketIO server - if it fails, the site will still work normally
try {
// Connect to the dedicated Socket.IO server using the dynamically generated URL from the template
const socket = io(socketio_url);
// Connect to Socket.IO on the same host/port, with path from template
const socket = io({
path: socketio_url, // This will be the path prefix like "/app/socket.io" from the template
transports: ['polling', 'websocket'], // Try WebSocket but fall back to polling
reconnectionDelay: 1000,
reconnectionAttempts: 5
});
// Connection status logging
socket.on('connect', function () {

View File

@@ -28,7 +28,7 @@
<meta name="theme-color" content="#ffffff">
<script>
const csrftoken="{{ csrf_token() }}";
const socketio_url="{{ get_socketio_url() }}";
const socketio_url="{{ get_socketio_path() }}/socket.io";
</script>
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='csrf.js')}}" defer></script>