socket.io
socket.io copied to clipboard
Socket connection is dropped on link download
Describe the bug Socket connection drops after download is initiated via hidden href link download in chrome. Chome : Version 103.0.5060.134 (Official Build) (64-bit), but it was reproducable with any recent version
To Reproduce
I've added code in main.js (see below) that will log to console all socket events to console and extra logic that checks each sent message text === "download" resulting a hidden link to be added and clicked which initiates download of index.html with browser default file open dialog.
// Sends a chat message
const sendMessage = () => {
let message = $inputMessage.val();
// Prevent markup from being injected into the message
message = cleanInput(message);
// if there is a non-empty message and a socket connection
if (message && connected) {
$inputMessage.val('');
addChatMessage({ username, message });
// tell server to execute 'new message' and send along one parameter
socket.emit('new message', message);
if(message === 'download'){
const a = document.createElement('a');
a.href = "/index.html";
a.download = `arm64.pkg`;
//a.target = '_blank';
a.click();
a.remove();
}
}
}
- Use Chat sample from this socketio repository https://github.com/socketio/socket.io/tree/main/examples/chat
- change version of socket.io to 4.5.1 and npm install
- launch server (npm start)
- launch 2 chat windows in chrome, open dev tools and observe socket events are working (e.g. when typeing you will see events are comming to connected clients)
- initiate download from one of clients
- observe that client that initiated download has dropped ws connection and doesn't receive any socket notification before it reconnects
WORKAROUNDS:
- use a.target = '_blank'; - but this creates negative UX by opening dialog in another window
- use empty target iframe for downloads
Socket.IO server/client version: 4.5.1
Client: full main.js code
$(function() {
const FADE_TIME = 150; // ms
const TYPING_TIMER_LENGTH = 400; // ms
const COLORS = [
'#e21400', '#91580f', '#f8a700', '#f78b00',
'#58dc00', '#287b00', '#a8f07a', '#4ae8c4',
'#3b88eb', '#3824aa', '#a700ff', '#d300e7'
];
// Initialize variables
const $window = $(window);
const $usernameInput = $('.usernameInput'); // Input for username
const $messages = $('.messages'); // Messages area
const $inputMessage = $('.inputMessage'); // Input message input box
const $loginPage = $('.login.page'); // The login page
const $chatPage = $('.chat.page'); // The chatroom page
const socket = io();
socket.onAny((eventName, ...args) => {
console.log(eventName, args);
});
// Prompt for setting a username
let username;
let connected = false;
let typing = false;
let lastTypingTime;
let $currentInput = $usernameInput.focus();
const addParticipantsMessage = (data) => {
let message = '';
if (data.numUsers === 1) {
message += `there's 1 participant`;
} else {
message += `there are ${data.numUsers} participants`;
}
log(message);
}
// Sets the client's username
const setUsername = () => {
username = cleanInput($usernameInput.val().trim());
// If the username is valid
if (username) {
$loginPage.fadeOut();
$chatPage.show();
$loginPage.off('click');
$currentInput = $inputMessage.focus();
// Tell the server your username
socket.emit('add user', username);
}
}
// Sends a chat message
const sendMessage = () => {
let message = $inputMessage.val();
// Prevent markup from being injected into the message
message = cleanInput(message);
// if there is a non-empty message and a socket connection
if (message && connected) {
$inputMessage.val('');
addChatMessage({ username, message });
// tell server to execute 'new message' and send along one parameter
socket.emit('new message', message);
if(message === 'download'){
const a = document.createElement('a');
a.href = "/index.html";
a.download = `index.html`;
//a.target = '_blank';
a.click();
a.remove();
}
}
}
// Log a message
const log = (message, options) => {
const $el = $('<li>').addClass('log').text(message);
addMessageElement($el, options);
}
// Adds the visual chat message to the message list
const addChatMessage = (data, options = {}) => {
// Don't fade the message in if there is an 'X was typing'
const $typingMessages = getTypingMessages(data);
if ($typingMessages.length !== 0) {
options.fade = false;
$typingMessages.remove();
}
const $usernameDiv = $('<span class="username"/>')
.text(data.username)
.css('color', getUsernameColor(data.username));
const $messageBodyDiv = $('<span class="messageBody">')
.text(data.message);
const typingClass = data.typing ? 'typing' : '';
const $messageDiv = $('<li class="message"/>')
.data('username', data.username)
.addClass(typingClass)
.append($usernameDiv, $messageBodyDiv);
addMessageElement($messageDiv, options);
}
// Adds the visual chat typing message
const addChatTyping = (data) => {
data.typing = true;
data.message = 'is typing';
addChatMessage(data);
}
// Removes the visual chat typing message
const removeChatTyping = (data) => {
getTypingMessages(data).fadeOut(function () {
$(this).remove();
});
}
// Adds a message element to the messages and scrolls to the bottom
// el - The element to add as a message
// options.fade - If the element should fade-in (default = true)
// options.prepend - If the element should prepend
// all other messages (default = false)
const addMessageElement = (el, options) => {
const $el = $(el);
// Setup default options
if (!options) {
options = {};
}
if (typeof options.fade === 'undefined') {
options.fade = true;
}
if (typeof options.prepend === 'undefined') {
options.prepend = false;
}
// Apply options
if (options.fade) {
$el.hide().fadeIn(FADE_TIME);
}
if (options.prepend) {
$messages.prepend($el);
} else {
$messages.append($el);
}
$messages[0].scrollTop = $messages[0].scrollHeight;
}
// Prevents input from having injected markup
const cleanInput = (input) => {
return $('<div/>').text(input).html();
}
// Updates the typing event
const updateTyping = () => {
if (connected) {
if (!typing) {
typing = true;
socket.emit('typing');
}
lastTypingTime = (new Date()).getTime();
setTimeout(() => {
const typingTimer = (new Date()).getTime();
const timeDiff = typingTimer - lastTypingTime;
if (timeDiff >= TYPING_TIMER_LENGTH && typing) {
socket.emit('stop typing');
typing = false;
}
}, TYPING_TIMER_LENGTH);
}
}
// Gets the 'X is typing' messages of a user
const getTypingMessages = (data) => {
return $('.typing.message').filter(function (i) {
return $(this).data('username') === data.username;
});
}
// Gets the color of a username through our hash function
const getUsernameColor = (username) => {
// Compute hash code
let hash = 7;
for (let i = 0; i < username.length; i++) {
hash = username.charCodeAt(i) + (hash << 5) - hash;
}
// Calculate color
const index = Math.abs(hash % COLORS.length);
return COLORS[index];
}
// Keyboard events
$window.keydown(event => {
// Auto-focus the current input when a key is typed
if (!(event.ctrlKey || event.metaKey || event.altKey)) {
$currentInput.focus();
}
// When the client hits ENTER on their keyboard
if (event.which === 13) {
if (username) {
sendMessage();
socket.emit('stop typing');
typing = false;
} else {
setUsername();
}
}
});
$inputMessage.on('input', () => {
updateTyping();
});
// Click events
// Focus input when clicking anywhere on login page
$loginPage.click(() => {
$currentInput.focus();
});
// Focus input when clicking on the message input's border
$inputMessage.click(() => {
$inputMessage.focus();
});
// Socket events
// Whenever the server emits 'login', log the login message
socket.on('login', (data) => {
connected = true;
// Display the welcome message
const message = 'Welcome to Socket.IO Chat – ';
log(message, {
prepend: true
});
addParticipantsMessage(data);
});
// Whenever the server emits 'new message', update the chat body
socket.on('new message', (data) => {
addChatMessage(data);
});
// Whenever the server emits 'user joined', log it in the chat body
socket.on('user joined', (data) => {
log(`${data.username} joined`);
addParticipantsMessage(data);
});
// Whenever the server emits 'user left', log it in the chat body
socket.on('user left', (data) => {
log(`${data.username} left`);
addParticipantsMessage(data);
removeChatTyping(data);
});
// Whenever the server emits 'typing', show the typing message
socket.on('typing', (data) => {
addChatTyping(data);
});
// Whenever the server emits 'stop typing', kill the typing message
socket.on('stop typing', (data) => {
removeChatTyping(data);
});
socket.on('disconnect', () => {
log('you have been disconnected');
});
socket.io.on('reconnect', () => {
log('you have been reconnected');
if (username) {
socket.emit('add user', username);
}
});
socket.io.on('reconnect_error', () => {
log('attempt to reconnect has failed');
});
});
Expected behavior Socket connection is not dropped on download
Platform:
- Device: [e.g. Dell xps 13 9360]
- OS: [e.g. windows 11]
Additional context
Hello! Could someone please answer this bug or recommend how to properly fix it
Hi Volodymyr,
I have a similar issue and found out that everything should work with socket.io-client version 3.0.5. This client version should work with the latest socket.io server version.
Noticing this bug as well, still persisting on 4.5.3
, our socket disconnects once a download link is clicked
I'm experiencing this as well with client 4.6.1
.
Same issue with client 4.5.4.
Does anyone have a working demo where I can test this out?
Hi! I wasn't able to reproduce the issue: https://github.com/socketio/socket.io-fiddle/tree/issues/socket.io/4436
Does setting closeOnBeforeunload: false
have an impact?
Reference: https://socket.io/docs/v4/client-options/#closeonbeforeunload
Ran into the same problem today and later found this issue.
Problem is with files that are on different host and unable to open in a browser. I.e. 'zip, exe, msi, ...'
https://github.com/sladdky/socket.io-fiddle
- Fixes for now could be adding
target="_blank"
(but it might be blocked anyway as popup) - Having the downloaded file on the same origin.
- Or right after link click add this code (the transport seems to drop without firing any error|exit|whatever event)
.... a.download = '' a.click() socket.disconnect() //call disconnect first, client still thinks it's connected socket.connect()