Skip to content

Instantly share code, notes, and snippets.

@br3nt
Forked from neilj/window-controller.js
Last active September 4, 2024 13:18
Show Gist options
  • Select an option

  • Save br3nt/c6aeec77fcef1a1f3907 to your computer and use it in GitHub Desktop.

Select an option

Save br3nt/c6aeec77fcef1a1f3907 to your computer and use it in GitHub Desktop.
Cross-tab window communication
function Dispatcher(channel_name, broadcast_to_self) {
this.channel_name = channel_name ? 'dispatcher:' + channel_name : 'dispatcher:default_channel';
this.id = Math.random();
this.isMaster = false;
this.others = {};
this.broadcast_to_self = broadcast_to_self === undefined ? true || !!broadcast_to_self;
this.callbacks = {};
window.addEventListener( 'storage', this, false );
window.addEventListener( 'unload', this, false );
this.broadcast( 'hello' );
var that = this;
var check = function check () {
that.check();
that._checkTimeout = setTimeout( check, 9000 );
};
var ping = function ping () {
that.sendPing();
that._pingTimeout = setTimeout( ping, 17000 );
};
this._checkTimeout = setTimeout( check, 500 );
this._pingTimeout = setTimeout( ping, 17000 );
}
Dispatcher.prototype.destroy = function () {
clearTimeout( this._pingTimeout );
clearTimeout( this._checkTimeout );
window.removeEventListener( 'storage', this, false );
window.removeEventListener( 'unload', this, false );
this.broadcast( 'bye' );
};
Dispatcher.prototype.handleEvent = function ( event ) {
if ( event.type === 'unload' ) {
this.destroy();
} else if ( event.key === this.channel_name ) {
var data = JSON.parse( event.newValue ), type = data.type;
switch ( type ) {
case 'ping':
case 'hello':
case 'bye':
this[ type ]( data );
break;
default:
try {
this.callbacks[ type ].call( this, data );
}
catch ( error ) {}
}
}
};
Dispatcher.prototype.sendPing = function () {
this.broadcast( 'ping' );
};
Dispatcher.prototype.hello = function ( event ) {
this.ping( event );
if ( event.id < this.id ) {
this.check();
} else {
this.sendPing();
}
};
Dispatcher.prototype.ping = function ( event ) {
this.others[ event.id ] = +new Date();
};
Dispatcher.prototype.bye = function ( event ) {
delete this.others[ event.id ];
this.check();
};
Dispatcher.prototype.check = function ( event ) {
var now = +new Date(),
takeMaster = true,
id;
for ( id in this.others ) {
if ( this.others[ id ] + 23000 < now ) {
delete this.others[ id ];
} else if ( id < this.id ) {
takeMaster = false;
}
}
if ( this.isMaster !== takeMaster ) {
this.isMaster = takeMaster;
this.masterDidChange( takeMaster );
}
};
Dispatcher.prototype.masterDidChange = function () {};
Dispatcher.prototype.broadcast = function ( type, data ) {
var event = {
id: this.id,
type: type
};
for ( var x in data ) {
event[x] = data[x];
}
try {
data = JSON.stringify( event );
localStorage.setItem( this.channel_name, data );
if (this.broadcast_to_self) this.callbacks[ type ].call( this, JSON.parse(data) );
} catch ( error ) {}
};
Dispatcher.prototype.on = function (event_name, callback) {
this.callbacks[event_name] = callback;
return this;
}
Dispatcher.prototype.off = function (event_name) {
delete this.callbacks[event_name];
}
@br3nt
Copy link
Author

br3nt commented Sep 4, 2024

Copilot

Creating a JavaScript library to manage multiple tabs with a single controller tab for server-sent events (SSE) is a great idea! Here's a high-level approach to achieve this:

  1. Setting Up the Controller Tab- Identify the Controller Tab: Use the localStorage or sessionStorage to store a flag indicating which tab is the controller.
  • Server-Sent Events (SSE): The controller tab will establish an SSE connection to receive events from the server.
  1. Broadcasting Events to Other Tabs- Inter-Tab Communication: Use the BroadcastChannel API to communicate between tabs.
  • Event Handling: The controller tab will listen for SSE and broadcast the events to other tabs using the BroadcastChannel.
  1. Handling Controller Tab Closure- Detecting Closure: Use the beforeunload event to detect when the controller tab is about to close.
  • Promoting a New Controller: When the controller tab is closed, other tabs should detect this and elect a new controller.
    Example ImplementationHere's a simplified example to get you started:
// Controller tab setup
const channel = new BroadcastChannel('tab-communication');
let isController = false;

function becomeController() {
  isController = true;
  const eventSource = new EventSource('/events');
  eventSource.onmessage = (event) => {
    channel.postMessage(event.data);
  };
  window.addEventListener('beforeunload', () => {
    localStorage.removeItem('controller');
    channel.postMessage('controller-closed');
  });
}

function checkController() {
  if (!localStorage.getItem('controller')) {
    localStorage.setItem('controller', 'true');
    becomeController();
  }
}

checkController();

channel.onmessage = (message) => {
  if (message.data === 'controller-closed' && !isController) {
    checkController();
  } else if (!isController) {
    // Handle the event data
    console.log('Received event:', message.data);
  }
};

// Other tabs setup
if (!isController) {
  channel.onmessage = (message) => {
    if (message.data === 'controller-closed') {
      checkController();
    } else {
      // Handle the event data
      console.log('Received event:', message.data);
    }
  };
}

Key Points- Controller Election: The checkController function ensures that a new controller is elected if the current one closes.

  • Event Broadcasting: The BroadcastChannel API is used to broadcast events from the controller tab to other tabs.
  • SSE Handling: The controller tab handles the SSE connection and broadcasts received events.
    This is a basic implementation to get you started. You might need to add more robust error handling and edge case management depending on your specific requirements. If you have any questions or need further assistance, feel free to ask!

@br3nt
Copy link
Author

br3nt commented Sep 4, 2024

Copilot

Great questions! Handling race conditions and tab crashes are crucial for ensuring the robustness of your solution. Here are some strategies to address these issues:
Preventing Race ConditionsTo prevent multiple tabs from simultaneously trying to become the controller, you can use a combination of localStorage and a timestamp-based locking mechanism. Here's how you can do it:

  • Locking Mechanism: Use localStorage to create a lock with a timestamp.
  • Atomic Operations: Ensure that the operations to check and set the controller are atomic.
    Example Implementation
const channel = new BroadcastChannel('tab-communication');
let isController = false;

function becomeController() {
  isController = true;
  const eventSource = new EventSource('/events');
  eventSource.onmessage = (event) => {
    channel.postMessage(event.data);
  };
  window.addEventListener('beforeunload', () => {
    localStorage.removeItem('controller');
    channel.postMessage('controller-closed');
  });
}

function checkController() {
  const now = Date.now();
  const controller = localStorage.getItem('controller');
  if (!controller || now - parseInt(controller) > 5000) { // 5 seconds timeout
    localStorage.setItem('controller', now.toString());
    becomeController();
  }
}

checkController();

channel.onmessage = (message) => {
  if (message.data === 'controller-closed' && !isController) {
    checkController();
  } else if (!isController) {
    // Handle the event data
    console.log('Received event:', message.data);
  }
};

Handling Tab CrashesTo handle situations where the controller tab crashes and the beforeunload event is not called, you can implement a heartbeat mechanism:

  • Heartbeat Mechanism: The controller tab periodically updates a timestamp in localStorage.
  • Timeout Detection: Other tabs check this timestamp to detect if the controller tab has crashed.
    Example Implementation
const HEARTBEAT_INTERVAL = 2000; // 2 seconds
const HEARTBEAT_TIMEOUT = 5000; // 5 seconds

function startHeartbeat() {
  setInterval(() => {
    if (isController) {
      localStorage.setItem('controller-heartbeat', Date.now().toString());
    }
  }, HEARTBEAT_INTERVAL);
}

function checkController() {
  const now = Date.now();
  const controller = localStorage.getItem('controller');
  const heartbeat = localStorage.getItem('controller-heartbeat');
  if (!controller || !heartbeat || now - parseInt(heartbeat) > HEARTBEAT_TIMEOUT) {
    localStorage.setItem('controller', now.toString());
    becomeController();
  }
}

startHeartbeat();
checkController();

channel.onmessage = (message) => {
  if (message.data === 'controller-closed' && !isController) {
    checkController();
  } else if (!isController) {
    // Handle the event data
    console.log('Received event:', message.data);
  }
};

Key Points- Locking Mechanism: Prevents race conditions by using a timestamp-based lock.

  • Heartbeat Mechanism: Ensures that a new controller is elected if the current one crashes.
  • Timeout Detection: Other tabs detect if the controller tab has stopped updating the heartbeat.
    These strategies should help you create a robust solution for managing multiple tabs with a single controller for server-sent events. If you have any more questions or need further assistance, feel free to ask!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment