I\'m trying to use a shared worker to maintain a list of all the windows/tabs of a web application. Therefore following code is used:
//lives in shared-worker.js
I have been neck deep in the documentation all week working around the same problem.
The problem is the MessagePort specification. The bad news being that it has no error handling, and no flag, method or event to determine whether it has been closed.
The good news is I have created a viable solution, but it's a lot of code.
Keep in mind even among the supporting browsers the activity is handled differently. For example Opera will throw an error if you attempt to message or close a closed port. The bad news is you have to use a try-catch to handle the error, the good news is you can use that feedback to close a port on at least one-side.
Chrome and Safari fail silently leaving you no feedback and no way to end invalid objects.
My solution involves delivery confirmation or a custom "callback" approach. You use a setTimeout and pass the ID for it to the SharedWorker with your command, and before processing the command it sends back a confirmation to cancel the timeout. That timeout is generally hooked to a closeConnection() method.
This takes a reactive approach instead of a pre-emptive, originally I toyed with using the TCP/IP protocol model but that involved creating more functions to handle each process.
Some Psuedo-Code as an example:
function customClose() {
try {
worker.port.close();
} catch (err) { /* For Opera */ }
}
function send() {
try {
worker.port.postMessage({command: "doSomething", content: "some Data", id: setTimeout(function() { customClose(); ); }, 1000);
} catch (err) { /* For Opera */ }
}
function respond(p, d) {
p.postMessage({ command: "confirmation", id: d.id });
}
function message(e) {// Attached to all ports onmessage
if (e.data.id) respond(this, e.data);
if (e.data.command) e.data.command(p, e.data);// Execute command if it exists passing context and content
}
I have placed a complete demonstration here: http://www.cdelorme.com/SharedWorker/
I am new to stack overflow, so I am not familiar with how they handle large code posts, but my full solution is two 150 line files.
Just using delivery confirmation alone is not perfect, so I have worked at improving it by adding additional components.
In particular I was investigating this for a ChatBox system, so I wanted to use EventSource (SSE), XHR, and WebSockets, only XHR is supported inside SharedWorker objects supposedly, which creates a limitation if I wanted to have the SharedWorker do all the server communication.
Plus since it needs to work for browsers without SharedWorker support I would be creating long-hand duplicate processing inside the SharedWorker which doesn't make a lot of sense.
So in the end if I implement SharedWorker it would be as a communication channel for the open tabs only, and one tab will be the Control Tab.
If the control tab is closed, the SharedWorker won't know, so I added a setInterval to the SharedWorker to send an empty response request every few seconds to all open ports. This allows Chrome and Safari to eliminate closed connections when no messages are being processed, and allows the control tab to change.
However, this also means if the SharedWorker process dies the tabs must have an interval to check in with the SharedWorker using the same approach every so often, allowing them to use the fallback approach of every-tab-for-themeselves that is inherent to all other browsers using the same code.
So, as you can see a combination of callbacks for delivery confirmation, setTimeout and setInterval must be used from both ends to maintain knowledge of connectivity. It can be done but it's a giant pain in the rear.
PortCollection
would come in handy but doesn't seem to be implemented in any browser.
It acts as an opaque array of MessagePort objects, thus allowing the objects to be garbage collected when they stop being relevant, while still allowing scripts to iterate over the MessagePort objects.
source; http://www.whatwg.org/specs/web-apps/current-work/multipage/web-messaging.html#portcollection
Edit; just rised an Issue for Chrome; http://crbug.com/263356
...How about using the approach you suggest in the edit, i.e. use a keep-alive ping, BUT:
Just before closing any unresponsive connection, send a "please reconnect" message through it, so that if a window isn't really closed, just busy, it'll know it has to re-connect?
This technique should probably be combined with sending explicit "I'm closing now" messages from window onunload events, as per @Adria 's solution, so that normal window termination is handled efficiently and without any delay.
This is still somewhat unreliable, in that very busy windows might drop off the SharedWorker's list temporarily, before later re-connecting... but actually I don't see how you could do much better: Consider that if a window hangs, practically speaking that's not going to be differentiable from a it being "busy" for some indefinitely long time, so you can't really catch one without catching the other (in any finite time, anyway).
Depending on your application, having very busy windows temporarily getting de-listed may or may not be a big problem.
Note that the keep-alive pings should be sent from the SharedWorker to windows, which should then respond: If you try simply using setTimout() in the windows, you run into the problem that setTimeout() on background windows can be long delayed (up to 1 second on current browsers I believe), while the SharedWorker's setTimeout()s should run on schedule (give or take a few ms), and idling background windows will wake up and respond immediately to posted SharedWorker messages.
Here's a neat little demo of this technique, that:
sharedworker.html
<!doctype html>
<head>
<title>Shared Worker Test</title>
<script type="text/javascript" src="sharedworker-host.js" async></script>
<script>
function windowConnected(init){ if (init) { document.title = "#"+thisWindowID; document.getElementById("idSpan").textContent = thisWindowID; } document.body.style.backgroundColor = "lightgreen"; }
function windowDisconnected(){ document.title = "#"+thisWindowID; document.body.style.backgroundColor = "grey"; }
function activeWindowChanged(){ document.getElementById("activeSpan").textContent = activeWindowID; document.title = "#"+thisWindowID+(windowIsActive?" [ACTIVE]":""); document.body.style.backgroundColor = (windowIsActive?"pink":"lightgreen"); }
function windowCountChanged(){ document.getElementById("countSpan").textContent = windowCount; }
function windowListChanged(){ document.getElementById("listSpan").textContent = otherWindowIDList.join(", "); }
function setActiveClick(){ if (setWindowActive) setWindowActive(); }
function longOperationClick(){ var s = "", start = Date.now(); while (Date.now()<(start+10000)) { s += Math.sin(Math.random()*9999999).toString; s = s.substring(s.length>>>1); } return !!s; }
window.addEventListener("unload",function(){window.isUnloading = true});
window.addEventListener("DOMContentLoaded",function(){window.DOMContentLoadedDone = true});
</script>
<style>
body {padding:40px}
span {padding-left:40px;color:darkblue}
input {margin:100px 60px}
</style>
</head>
<body>
This Window's ID: <span id="idSpan">???</span><br><br>
Active Window ID: <span id="activeSpan">???</span><br><br>
Window Count: <span id="countSpan">???</span><br><br>
Other Window IDs: <span id="listSpan">???</span><br><br>
<div>
<input type="button" value="Set This Window Active" onclick="setActiveClick()">
<input type="button" value="Perform 10-second blocking computation" onclick="longOperationClick()">
</div>
</body>
</html>
sharedworker-host.js
{ // this block is just to trap 'let' variables inside
let port = (new SharedWorker("sharedworker.js")).port;
var thisWindowID = 0, activeWindowID = 0, windowIsConnected = false, windowIsActive = false, windowCount = 0, otherWindowIDList = [];
//function windowConnected(){} //
//function windowDisconnected(){} //
//function activeWindowChanged(){} // do something when changes happen... these need to be implemented in another file (e.g. in the html in an inline <script> tag)
//function windowCountChanged(){} //
//function windowListChanged(){} //
function setWindowActive() { if (thisWindowID) port.postMessage("setActive"); }
function sortedArrayInsert(arr,val) { var a = 0, b = arr.length, m, v; if (!b) arr.push(val); else { while (a<b) if (arr[m = ((a+b)>>>1)]<val) a = m+1; else b = m; if (arr[a]!==val) arr.splice(a,0,val); }}
function sortedArrayDelete(arr,val) { var a = 0, b = arr.length, m, v; if (b) { while (a<b) if (arr[m = ((a+b)>>>1)]<val) a = m+1; else b = m; if (arr[a]===val) arr.splice(a,1); }}
let msgHandler = function(e)
{
var data = e.data, msg = data[0];
if (!(windowIsConnected||(msg==="setID")||(msg==="disconnected"))) { windowIsConnected = true; windowConnected(false); }
switch (msg)
{
case "ping": port.postMessage("pong"); break;
case "setID": thisWindowID = data[1]; windowConnected(windowIsConnected = true); break;
case "setActive": if (activeWindowID!==(activeWindowID = data[1])) { windowIsActive = (thisWindowID===activeWindowID); activeWindowChanged(); } break;
case "disconnected": port.postMessage("pong"); windowIsConnected = windowIsActive = false; if (thisWindowID===activeWindowID) { activeWindowID = 0; activeWindowChanged(); } windowDisconnected(); break;
// THE REST ARE OPTIONAL:
case "windowCount": if (windowCount!==(windowCount = data[1])) windowCountChanged(); break;
case "existing": otherWindowIDList = data[1].sort((a,b) => a-b); windowListChanged(); break;
case "opened": sortedArrayInsert(otherWindowIDList,data[1]); windowListChanged(); break;
case "closed": sortedArrayDelete(otherWindowIDList,data[1]); windowListChanged(); break;
}
};
if (!window.isUnloading)
{
if (window.DOMContentLoadedDone) port.onmessage = msgHandler; else window.addEventListener("DOMContentLoaded",function(){port.onmessage = msgHandler});
window.addEventListener("unload",function(){port.postMessage("close")});
}
}
sharedworker.js
// This shared worker:
// (a) Provides each window with a unique ID (note that this can change if a window reconnects due to an inactivity timeout)
// (b) Maintains a list and a count of open windows
// (c) Maintains a single "active" window, and keeps all connected windows apprised of which window that is
//
// It needs to RECEIVE simple string-only messages:
// "close" - when a window is closing
// "setActive" - when a window wants to be set to be the active window
// "pong" (or in fact ANY message at all other than "close") - must be received as a reply to ["ping"], or within (2 x pingTimeout) milliseconds of the last recived message, or the window will be considered closed/crashed/hung
//
// It will SEND messages:
// ["setID",<unique window ID>] - when a window connects, it will receive it's own unique ID via this message (this must be remembered by the window)
// ["setActive",<active window ID>] - when a window connects or reconnects, or whenever the active window changes, it will receive the ID of the "active" window via this message (it can compare to it's own ID to tell if it's the active window)
// ["ping"] - a window sent this message should send back a "pong" message (or actually ANY message except "close") to confirm it's still alive
// ["disconnected"] - when a window is disconnected due to a ping timeout, it'll recieve this message; assuming it hasn't closed it should immediately send a "pong", in order to reconnect.
// AND OPTIONALLY (REMOVE lines noted in comments to disable):
// IF EACH WINDOW NEEDS (ONLY) A RUNNING COUNT OF TOTAL CONNECTED WINDOWS:
// ["windowCount",<count of connected windows>] - sent to a window on connection or reconnection, and whenever the window count changes
// OR ALTERNATIVELY, IF EACH WINDOW NEEDS A COMPLETE LIST OF THE IDs OF ALL OTHER WINDOWS:
// ["existing",<array of existing window IDs>] - sent upon connectionor reconnection
// ["opened",<ID of just-opened window>] - sent to all OTHER windows, when a window connects or reconnects
// ["closed",<ID of closing window>] - sent to all OTHER windows, when a window disconnects (either because it explicitly sent a "close" message, or because it's been too long since its last message (> pingTimeout))
const pingTimeout = 1000; // milliseconds
var count = 0, lastID = 0, activeID = 0, allPorts = {};
function handleMessage(e)
{
var port = this, msg = e.data;
if (port.pingTimeoutID) { clearTimeout(port.pingTimeoutID); port.pingTimeoutID = 0; }
if (msg==="close") portClosed(port,false); else
{
if (!allPorts[port.uniqueID]) connectPort(port,false); // reconnect disconnected port
if (msg==="setActive") setActive(port.uniqueID);
port.pingTimeoutID = setTimeout(function(){pingPort(port)},pingTimeout);
}
}
function setActive(portID) // if portID is 0, this actually sets the active port ID to the first port in allPorts{} if present (or 0 if no ports are connected)
{
if (activeID!==portID)
{
activeID = portID;
for(var pID in allPorts) if (allPorts.hasOwnProperty(pID)) allPorts[pID].postMessage(["setActive",(activeID||(activeID = +pID))]);
}
}
function pingPort(port)
{
port.postMessage(["ping"]);
port.pingTimeoutID = setTimeout(function(){portClosed(port,true)},pingTimeout);
}
function portClosed(port,fromTimeout)
{
var portID = port.uniqueID;
if (fromTimeout) port.postMessage(["disconnected"]); else { clearTimeout(port.pingTimeoutID); port.close(); }
port.pingTimeoutID = 0;
if (allPorts[portID])
{
delete allPorts[portID];
--count;
if (activeID===portID) setActive(0);
for(var pID in allPorts) if (allPorts.hasOwnProperty(pID)) allPorts[pID].postMessage(["closed",portID]); // REMOVE if windows don't need a list of all other window IDs
for(var pID in allPorts) if (allPorts.hasOwnProperty(pID)) allPorts[pID].postMessage(["windowCount",count]); // REMOVE if change of window-count doesn't need to be broadcast to all windows
}
}
function newConnection(e)
{
var port = e.source;
port.uniqueID = ++lastID;
port.onmessage = handleMessage;
connectPort(port,true);
}
function connectPort(port,initialConnection)
{
var portID = port.uniqueID;
port.postMessage(["existing",Object.keys(allPorts).map(x => +x)]);for(var pID in allPorts) if (allPorts.hasOwnProperty(pID)) allPorts[pID].postMessage(["opened",portID]); // REMOVE if windows don't need a list of all other window IDs
allPorts[portID] = port;
++count;
for(var pID in allPorts) if (allPorts.hasOwnProperty(pID)) allPorts[pID].postMessage(["windowCount",count]); // REMOVE if change of window-count doesn't need to be broadcast to all windows
if (initialConnection) { port.postMessage(["setID",lastID]); port.pingTimeoutID = setTimeout(function(){pingPort(port)},pingTimeout); }
if (!activeID) setActive(portID); else port.postMessage(["setActive",activeID]);
}
onconnect = newConnection;
This is only as reliable as beforeunload, but seems to work (tested in Firefox and Chrome). I definitely favour it over a polling solution.
// Tell the SharedWorker we're closing
addEventListener( 'beforeunload', function()
{
port.postMessage( {command:'closing'} );
});
Then handle the cleanup of the port object in the SharedWorker.
e.ports[0].onmessage = function( e )
{
const port = this,
data = e.data;
switch( data.command )
{
// Tab closed, remove port
case 'closing': myConnections.splice( myConnections.indexOf( port ), 1 );
break;
}
}