socketio / socket.io

Realtime application framework (Node.JS server)
https://socket.io
MIT License
61.15k stars 10.11k forks source link

Socket connection is dropped on link download #4436

Closed volodymyr-ilnytskyi-tfs closed 2 weeks ago

volodymyr-ilnytskyi-tfs commented 2 years ago

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();
      }
    }
  }
  1. Use Chat sample from this socketio repository https://github.com/socketio/socket.io/tree/main/examples/chat
  2. change version of socket.io to 4.5.1 and npm install
  3. launch server (npm start)
  4. 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)
  5. initiate download from one of clients
  6. observe that client that initiated download has dropped ws connection and doesn't receive any socket notification before it reconnects

WORKAROUNDS:

  1. use a.target = '_blank'; - but this creates negative UX by opening dialog in another window
  2. 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:

Additional context

volodymyr-ilnytskyi-tfs commented 2 years ago

Hello! Could someone please answer this bug or recommend how to properly fix it

CapLek commented 2 years ago

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.

andrewhawk1ns commented 2 years ago

Noticing this bug as well, still persisting on 4.5.3, our socket disconnects once a download link is clicked

DanielPower commented 1 year ago

I'm experiencing this as well with client 4.6.1.

robertsutherland commented 1 year ago

Same issue with client 4.5.4.

haneenmahd commented 1 year ago

Does anyone have a working demo where I can test this out?

darrachequesne commented 1 year ago

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

sladdky commented 1 year ago

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

darrachequesne commented 2 weeks ago

For future readers:

The closeOnBeforeunload option now defaults to false since socket.io-client@4.7.1.

Reference: https://socket.io/docs/v4/client-options/#closeonbeforeunload