statum-worker.js 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. /*!
  2. * Statum realtime channel worker.
  3. *
  4. * Shared across every tab of an origin via `SharedWorker`. It owns the single
  5. * realtime connection to `GET /_statum/channel`, reads the channel id from the
  6. * stream's first `event: channel`, reference-counts slot subscriptions across
  7. * all connected tabs, manages them with `PATCH /_statum/channel/{id}`, and
  8. * relays incoming `set`/`clear` events to every tab through the cross-tab
  9. * `BroadcastChannel` named "statum" (which the main library already listens
  10. * on). The worker never touches Web Storage or the DOM; tabs apply events and
  11. * persist frames themselves.
  12. *
  13. * Distribution: a standalone script (served at `/_statum/worker.js` by
  14. * default, overridable with `stm-worker`). It has no dependencies.
  15. */
  16. (function () {
  17. 'use strict';
  18. var CHANNEL_NAME = 'statum';
  19. var channelUrl = '/_statum/channel'; // default; overridden by a tab's `init` message
  20. var bc = null; // BroadcastChannel to the tabs
  21. var es = null; // EventSource to /_statum/channel
  22. var channelId = null;
  23. var refs = new Map(); // slotKey -> subscription count (across all ports)
  24. var portKeys = new Map(); // MessagePort -> Set(slotKey)
  25. var pending = { subscribe: [], unsubscribe: [] };
  26. var patchScheduled = false;
  27. function ensureBroadcast() {
  28. if (bc || typeof BroadcastChannel === 'undefined') return;
  29. try { bc = new BroadcastChannel(CHANNEL_NAME); } catch (e) { bc = null; }
  30. }
  31. function postToTabs(message) {
  32. ensureBroadcast();
  33. if (bc) { try { bc.postMessage(message); } catch (e) {} }
  34. }
  35. function openStream() {
  36. if (es) return;
  37. try {
  38. es = new EventSource(channelUrl, { withCredentials: true });
  39. } catch (e) { es = null; return; }
  40. es.addEventListener('channel', function (ev) {
  41. channelId = ev.data;
  42. flushPatch();
  43. });
  44. es.addEventListener('set', function (ev) {
  45. var frame;
  46. try { frame = JSON.parse(ev.data); } catch (e) { return; }
  47. postToTabs({ kind: 'set', frame: frame });
  48. });
  49. es.addEventListener('clear', function (ev) {
  50. postToTabs({ kind: 'clear', key: ev.data });
  51. });
  52. es.onerror = function () { /* EventSource reconnects automatically */ };
  53. }
  54. function closeStream() {
  55. if (es) { try { es.close(); } catch (e) {} es = null; }
  56. channelId = null;
  57. }
  58. function schedulePatch() {
  59. if (patchScheduled) return;
  60. patchScheduled = true;
  61. Promise.resolve().then(function () { patchScheduled = false; flushPatch(); });
  62. }
  63. function flushPatch() {
  64. if (!channelId) return;
  65. var sub = pending.subscribe, uns = pending.unsubscribe;
  66. if (!sub.length && !uns.length) return;
  67. pending = { subscribe: [], unsubscribe: [] };
  68. var body = {};
  69. if (sub.length) body.subscribe = sub;
  70. if (uns.length) body.unsubscribe = uns;
  71. try {
  72. fetch(channelUrl + '/' + encodeURIComponent(channelId), {
  73. method: 'PATCH',
  74. headers: { 'Content-Type': 'application/json' },
  75. credentials: 'same-origin',
  76. body: JSON.stringify(body)
  77. }).catch(function () {});
  78. } catch (e) {}
  79. }
  80. function addSub(port, key) {
  81. var keys = portKeys.get(port);
  82. if (!keys) { keys = new Set(); portKeys.set(port, keys); }
  83. if (keys.has(key)) return; // this port already subscribed
  84. keys.add(key);
  85. var c = refs.get(key) || 0;
  86. refs.set(key, c + 1);
  87. if (c === 0) {
  88. openStream();
  89. pending.subscribe.push(key);
  90. schedulePatch();
  91. }
  92. }
  93. function removeSub(port, key) {
  94. var keys = portKeys.get(port);
  95. if (!keys || !keys.has(key)) return;
  96. keys.delete(key);
  97. var c = refs.get(key) || 0;
  98. if (c <= 1) {
  99. refs.delete(key);
  100. pending.unsubscribe.push(key);
  101. schedulePatch();
  102. if (refs.size === 0) setTimeout(function () { if (refs.size === 0) closeStream(); }, 0);
  103. } else {
  104. refs.set(key, c - 1);
  105. }
  106. }
  107. function disconnectPort(port) {
  108. var keys = portKeys.get(port);
  109. if (keys) keys.forEach(function (key) { removeSub(port, key); });
  110. portKeys.delete(port);
  111. }
  112. function handlePortMessage(port, ev) {
  113. var msg = ev.data;
  114. if (!msg) return;
  115. if (msg.url) channelUrl = msg.url; // {op:'init', url}
  116. if (msg.op === 'subscribe' && msg.key != null) addSub(port, msg.key);
  117. else if (msg.op === 'unsubscribe' && msg.key != null) removeSub(port, msg.key);
  118. else if (msg.op === 'disconnect') disconnectPort(port);
  119. }
  120. // SharedWorker entry point: each connecting tab gets its own MessagePort.
  121. self.onconnect = function (e) {
  122. var port = e.ports[0];
  123. portKeys.set(port, new Set());
  124. port.onmessage = function (ev) { handlePortMessage(port, ev); };
  125. port.start();
  126. // Port closure (e.g. tab crash) is not reliably signalled, so refcounts
  127. // for an abruptly-closed tab are reclaimed when the worker itself is torn
  128. // down; a graceful close sends an explicit `disconnect` message first.
  129. };
  130. })();