@yz-social/civildefense.io 5.0.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,18 +37,26 @@ const PUBLISH_TIMEOUT = 24 * 60 * 60e3; // Delete after 24 hours.
37
37
  const TRACK_TIMEOUT = PUBLISH_TIMEOUT;
38
38
 
39
39
  let invoke;
40
- export function setReceiver(receiver) { // Set the means by which we fire events over a connection.
40
+ export function setSender(receiver) { // Set the means by which we fire events over a direct connection.
41
41
  invoke = receiver; // E.g. (nodeId, handlerId, envelope) => getSocket(nodeId).send(JSON.stringify([handlerId, envelope]));
42
42
  }
43
- async function fireEvent(...rest) { // Send envelope to a subscribed hander.
44
- try {
45
- invoke(...rest);
46
- } catch (error) { // Error sending, e.g., nodeTag is gone and we had not yet noticed.
43
+
44
+ function directFireEvent(...rest) { // Send envelope to a subscribed hander.
45
+ return invoke(...rest).catch(async error => { // Error sending, e.g., nodeTag is gone and we had not yet noticed.
46
+ console.log(new Date(), error.message || error);
47
47
  const [nodeTag, id, envelope] = rest;
48
48
  await deleteSubscriber(nodeTag);
49
- const subscription = await store.get('sub', await deriveTopicId(envelope.topic), id);
50
- if (subscription) push(envelope, subscription, PUBLISH_TIMEOUT);
51
- }
49
+ const topic = envelope.topic;
50
+ const topicId = await deriveTopicId(topic);
51
+ const subscription = await store.get('sub', topicId, id);
52
+ console.log('push error activation:', topic, topicId, id, subscription);
53
+ if (subscription) push(envelope, subscription, PUBLISH_TIMEOUT); // Do not wait for push.
54
+ });
55
+ }
56
+
57
+ let burstQueue = Promise.resolve();
58
+ function sendBurst(thunk) { // Serialize outbursts so that we and a client that subscribed to a bunch both have time to send/accept ack's.
59
+ burstQueue = burstQueue.then(thunk);
52
60
  }
53
61
 
54
62
  export async function subscribe(topic, nodeTag, {since = 'all', pushData = null}) {
@@ -58,27 +66,34 @@ export async function subscribe(topic, nodeTag, {since = 'all', pushData = null}
58
66
  const id = uuidv4();
59
67
  await store.set('sub', topicId, nodeTag, id, SUBSCRIPTION_TIMEOUT);
60
68
  // If pushData, store it separately by nodeTag until it needs to be activated.
61
- if (pushData) await store.set('track', topicId, nodeTag, pushData, TRACK_TIMEOUT);
62
- else store.remove('track', topicId, nodeTag);
69
+ if (pushData) {
70
+ console.log('track push sub', topicId, nodeTag, pushData.endpoint.slice(0, 80));
71
+ await store.set('track', topicId, nodeTag, pushData, TRACK_TIMEOUT);
72
+ } else {
73
+ store.remove('track', topicId, nodeTag);
74
+ }
63
75
  if (since) { // invoke handler on any sticky data, but only after we have told client the subscription id. TODO: is there a better way?
64
76
  setTimeout(async () => {
65
- let lastEnvelope = null, lastTime = 0;
66
- for (const envelope of await store.values('pub', topicId)) {
67
- switch (since) {
68
- case 'all':
69
- await fireEvent(nodeTag, id, envelope);
70
- break;
71
- case 'latest':
72
- if (envelope.ts > lastTime) {
73
- lastTime = envelope.ts;
74
- lastEnvelope = envelope;
77
+ //sendBurst(async () => {
78
+ let lastEnvelope = null, lastTime = 0;
79
+ for (const envelope of await store.values('pub', topicId)) {
80
+ if (!await store.get('sub', topicId, nodeTag)) return;
81
+ switch (since) {
82
+ case 'all':
83
+ await directFireEvent(nodeTag, id, envelope);
84
+ break;
85
+ case 'latest':
86
+ if (envelope.ts > lastTime) {
87
+ lastTime = envelope.ts;
88
+ lastEnvelope = envelope;
89
+ }
90
+ break;
91
+ default: // Must be a timestamp
92
+ if (envelope.ts === since) await directFireEvent(nodeTag, id, envelope);
75
93
  }
76
- break;
77
- default: // Must be a timestamp
78
- if (envelope.ts === since) await fireEvent(nodeTag, id, envelope);
79
94
  }
80
- }
81
- if (lastEnvelope) fireEvent(nodeTag, id, lastEnvelope);
95
+ if (lastEnvelope) directFireEvent(nodeTag, id, lastEnvelope);
96
+ //});
82
97
  }, 100);
83
98
  }
84
99
  return {topicName: {name, region, owner, write}, topicId, id};
@@ -89,26 +104,28 @@ export async function unsubscribe(topic, nodeTag, {pushId}) {
89
104
  // Additionally, an activated sticky pushId will be removed if specified - it is the nodeTag from a previous session).
90
105
 
91
106
  const topicId = await deriveTopicId(topic);
92
- let id = await store.remove('sub', topicId, nodeTag);
107
+ let id = await store.remove('sub', topicId, nodeTag), tracked;
93
108
  // Also remove any sticky push subscription:
94
109
  // If not yet promoted to sub, it's in track under our current, protectable nodeTag.
95
110
  await store.remove('track', topicId, nodeTag);
96
111
  // Otherwise, it might have been activated in sub under pushId (i.e., a previous session nodeTag).
97
112
  // TODO: pushId should probably be a JWS that was signed in the subscribing node's previous session by the pushId,
98
113
  // thus proving that the request actually came from the succcessor to that node.
99
- if (pushId) pushId = await store.remove('sub', topicId, pushId); // pushId must match that used during activation.
100
- return {ok: !!id, id, pushId}; // Axona doesn't return the id(s) of the subscription(s), but it is convenient for us to do so.
114
+ if (pushId) tracked = await store.remove('sub', topicId, pushId); // pushId must match that used during activation.
115
+ //if (pushId) console.log('normal unsubscribe remove tracking', pushId, tracked?.endpoint.slice(0, 80));
116
+ return {ok: !!id, id, pushId: tracked}; // Axona doesn't return the id(s) of the subscription(s), but it is convenient for us to do so.
101
117
  }
102
118
 
103
119
  export async function deleteSubscriber(nodeTag) {
104
- // The node is gone: lost connection, or fireEvent failed.
105
-
120
+ // The node is gone: lost connection, or directFireEvent failed.
121
+ console.log('deleteSubscriber', nodeTag);
106
122
  for (const topicId of await store.topics('sub')) { // Remove in all topics.
107
- await store.remove('sub', topicId, nodeTag);
123
+ const sub = await store.remove('sub', topicId, nodeTag);
124
+ if (sub) console.log('removed sub', topicId, nodeTag, sub);
108
125
  // Activate pending push subscription, if any, by moving it from 'track' to active 'sub'.
109
126
  const pushSubscription = await store.remove('track', topicId, nodeTag);
110
127
  if (pushSubscription) {
111
- //console.log('activiting', topicId, nodeTag, pushSubscription);
128
+ console.log('activating', topicId, nodeTag, pushSubscription.endpoint.slice(0, 80));
112
129
  await store.set('sub', topicId, nodeTag, pushSubscription, TRACK_TIMEOUT);
113
130
  }
114
131
  }
@@ -122,9 +139,11 @@ async function handleEvents(topicId, envelope) {
122
139
  // Each sub's handlerInfo can be an ordinary subscription nodeId, or a sticky push subscription data.
123
140
  return Promise.all(subs.map(([nodeTag, handlerInfo]) => {
124
141
  if (isConnectedSubscription(handlerInfo)) { // nodeId
125
- return fireEvent(nodeTag, handlerInfo, envelope);
142
+ return directFireEvent(nodeTag, handlerInfo, envelope);
126
143
  } else { // activated sticky push subscription data object.
127
- return push(envelope, handlerInfo, PUBLISH_TIMEOUT);
144
+ console.log('pushing for nodeId', nodeTag, handlerInfo?.endpoint.slice(0, 80));
145
+ push(envelope, handlerInfo, PUBLISH_TIMEOUT);
146
+ return null; // Do not wait for push
128
147
  }
129
148
  }));
130
149
  }
@@ -153,3 +172,5 @@ export async function unpublish(topic, msgId, {signWith}) {
153
172
  await handleEvents(topicId, envelope);
154
173
  return {ok: true};
155
174
  }
175
+
176
+ export const operators = {setSender, subscribe, unsubscribe, deleteSubscriber, publish, unpublish};
@@ -23,6 +23,6 @@ export async function push(envelope, {applicationServerKey, applicationId, ...su
23
23
  // to correlate known topic activity with someone's push records obtained from the service.
24
24
  const reply = await webpush.sendNotification?.(subscription, JSON.stringify(envelope), options)
25
25
  .catch(error => console.error(`${error.message} for push ${subscription?.endpoint}.`));
26
- //console.log('push', {envelope, subscription, options, reply});
26
+ console.log('push', {msgId: envelope.msgId, endpoint: subscription.endpoint, status: reply?.statusCode});
27
27
  return reply;
28
28
  }
@@ -1,6 +1,6 @@
1
1
  const { Request, Response, URL, localStorage, BroadcastChannel } = globalThis;
2
2
  import { appVersion } from './versions.js';
3
- import { resetInactivityTimer, clickTip, closeAbout } from './main.js';
3
+ import { resetInactivityTimer, clickTip, closeAbout, delay } from './main.js';
4
4
  import { openDisplay } from './display.js';
5
5
  import { go, getShareableURL } from './alert.js';
6
6
  import { Int } from './translations.js';
@@ -79,7 +79,10 @@ async function installUpdate(event, newVersion) {
79
79
  event.stopPropagation();
80
80
  event.target.textContent = Int`Installing...`; // In case there is some delay, tell the user what we're trying to do. Will be cleared with reload.
81
81
  event.target.disabled = true;
82
- await caches.delete(appVersion); // Must be before cacheSource, or we'll just recache the same files!
82
+ // Must be before cacheSource, or we'll just recache the same files!
83
+ const cacheNames = await caches.keys();
84
+ await Promise.all(cacheNames.map(name => caches.delete(name))); // Includes ALL cache storage, such as seen notifications.
85
+
83
86
  await cacheSource(newVersion);
84
87
  closeAbout();
85
88
  // Reload, but convince all browsers to re-"fetch" (through the new service worker that is now running).
@@ -141,6 +144,8 @@ await navigator.serviceWorker
141
144
  resolveCached?.(params);
142
145
  break;
143
146
  case 'go':
147
+ await delay();
148
+ resetInactivityTimer();
144
149
  go(params);
145
150
  break;
146
151
  default:
@@ -1,6 +1,6 @@
1
1
  const { Request, Response, URL, clients} = self;
2
2
  // Little point in trying to automatically pull this through package.json, as we need a byte change in THIS file to trigger a new worker.
3
- const serviceVersion = '5.0.0';
3
+ const serviceVersion = '5.1.1';
4
4
 
5
5
  const cacheList = [ // The files we need.
6
6
  "/",
@@ -174,7 +174,7 @@ self.addEventListener('activate', async event => {
174
174
  // Apply to running clients now, so that first fresh install sees updatefound event.
175
175
  // Otherwise, the service worker wouldn't fire until the code NEXT time the page loads after
176
176
  // registration, and thus the initial load would not see any updatefound events.
177
- event.waitUntil(self.clients.claim());
177
+ event.waitUntil(self.clients.claim().then(expireStaleAlerts));
178
178
  });
179
179
 
180
180
  self.addEventListener('fetch', event => {
@@ -203,8 +203,36 @@ async function cacheSource(version, event) { // Cache source in the given versio
203
203
  return version;
204
204
  }
205
205
 
206
- const issued = new Set();
207
- function showNotification({lat, lng, issuedTime, hashtag, alert, body = '', force = false}) { // Promise to show a platform notification. There are two paths to here:
206
+ // The service worker is stopped and restarted for events, and so we can't keep an in-memory set of issued alerts.
207
+ // Here we keep it in cache, and manually clean up expired entries.
208
+ const alertCache = 'alerts-' + serviceVersion;
209
+ function hasSeenAlert(seenKey) { // Have we noted seenKey before?
210
+ return caches.open(alertCache).then(cache => cache.match(seenKey));
211
+ }
212
+ function noteSeenAlert(seenKey, issuedTime) { // Record seenKey and it's expiration timestamp.
213
+ return caches.open(alertCache).then(cache => cache.put(seenKey, new Response(issuedTime.toString())));
214
+ }
215
+ const ALERT_EXPIRATION_MS = 24 * 60 * 60e3;
216
+ let lastMaintenance = 0;
217
+ const SERVICE_INTERVAL = 60 * 60e3;
218
+ async function expireStaleAlerts() { // Maintenance: declutter notifications and cache storage.
219
+ const now = Date.now();
220
+ if (now < lastMaintenance + SERVICE_INTERVAL) return;
221
+ lastMaintenance = now;
222
+ const cache = await caches.open(alertCache);
223
+ const firstSafeIssuedTime = Date.now() - ALERT_EXPIRATION_MS;
224
+ const firstSafeIssuedString = firstSafeIssuedTime.toString();
225
+ for (const key of await cache.keys()) {
226
+ const response = await cache.match(key);
227
+ if (!response) console.error('how can there not be a response at', key);
228
+ else if (firstSafeIssuedString > await response.text()) {console.log('removing stale seen', key); cache.delete(key); }
229
+ }
230
+ for (const notification of await self.registration.getNotifications()) {
231
+ if (firstSafeIssuedTime > notification.data.issuedTime) {console.log('removing stale alert', notification.close());}
232
+ }
233
+ }
234
+
235
+ async function showNotification({lat, lng, issuedTime, hashtag, alert, body = '', force = false}) { // Promise to show a platform notification. There are two paths to here:
208
236
  // 1. The app handles data from network connections, and determines that it should alert the user.
209
237
  // In this case, the app sends the data to this service worker.
210
238
  // 2. An upstream node would like to send the data over the network, but finds that we are not connected,
@@ -213,21 +241,24 @@ function showNotification({lat, lng, issuedTime, hashtag, alert, body = '', forc
213
241
  // in path 2, so it has to be here if we want to have the code in just one place.
214
242
  // Any filtering (e.g., do not show notifications for one's own alerts) happens upstream of here. (We don't know the Agent.current and the sender isn't in the notification.)
215
243
  const seenKey = alert + body; // If we click on a notification for an off-screen alert, we may process the alert again and try to notify again.
216
- if (!force && issued.has(seenKey)) return null;
217
- issued.add(seenKey);
244
+ const seen = !force && await hasSeenAlert(seenKey);
245
+ console.log({seenKey, seen});
246
+ if (seen) return null;
247
+ noteSeenAlert(seenKey, issuedTime);
218
248
  const base = location.href;
219
249
  const timestamp = issuedTime;
220
250
  const icon = new URL('./images/civil-defense-192.png', base).href;
221
251
  const queryString = `./?tags=${encodeURIComponent(hashtag)}&lat=${lat}&lng=${lng}&alert=${alert}`;
222
252
  const url = new URL(queryString, base).href; // For opening page when it has been closed.
223
- const data = {lat, lng, url};
253
+ const data = {lat, lng, url, issuedTime};
224
254
  const options = {icon, timestamp, body, data, tag: alert, renotify: true};
225
255
  console.log('showNotification', {seenKey, hashtag, options});
226
- return self.registration.showNotification(hashtag, options);
256
+ await self.registration.showNotification(hashtag, options);
257
+ return expireStaleAlerts();
227
258
  }
228
259
 
229
260
  function showNotificationFromEnvelope({deleted, message, msgId}) { // We get the generic, application-independent envelope.
230
- //console.log('envelope', message, msgId);
261
+ console.log('received push', msgId, deleted ? 'cancel' : '');
231
262
  if (deleted) return cancelNotification(msgId);
232
263
  const {issuedTime, hashtag, payload, alert = msgId} = message;
233
264
  let {lat, lng, message:body, name = ''} = payload;
@@ -237,7 +268,7 @@ function showNotificationFromEnvelope({deleted, message, msgId}) { // We get the
237
268
 
238
269
  async function cancelNotification(tag, body = '') { // Cancel those that match tag, and if body, then only those also matching body.
239
270
  for (const notification of await self.registration.getNotifications({tag})) {
240
- console.log('cancel', {tag, body, notification});
271
+ console.log('cancel notification', {tag, body, notification});
241
272
  if (!body || (body === notification.body)) notification.close();
242
273
  }
243
274
  }
@@ -205,6 +205,7 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
205
205
  bottom: 55px;
206
206
  max-height: 45dvh; /* leaving room for portrait keyboard. landscape keyboard has no room at all *\/ */
207
207
  overflow-y: auto;
208
+ overscroll-behavior: contain; /* don't chain scroll to page */
208
209
  width: fit-content;
209
210
  min-width: var(--combobox-minWidth);
210
211
  z-index: 1000;
@@ -253,7 +254,7 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
253
254
  }
254
255
  .alert-marker {
255
256
  text-shadow: 8px -3px 10px rgb(0 0 0 / 60%);
256
- animation: pulse ease 0.8s 1;
257
+ animation: pulse08 ease 0.8s 1;
257
258
  transition: opacity 0.4s;
258
259
  }
259
260
  .alert-commented {
@@ -288,7 +289,7 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
288
289
  left: -5px;
289
290
  background-image: radial-gradient(transparent 30%, var(--md-sys-color-primary), transparent 70%);
290
291
  transition: opacity 0.8s;
291
- animation: pulse ease 0.8s 1;
292
+ animation: pulse08 ease 0.8s 1;
292
293
  }
293
294
  .aggregate.alert-pin.starting {
294
295
  font-size: 0px;
@@ -296,19 +297,43 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
296
297
  .aggregate.alert-pin.starting:after {
297
298
  opacity: 0;
298
299
  }
299
- @keyframes pulse {
300
- 0% { filter: saturate(1); }
301
- 25% { filter: saturate(4); }
302
- 50% { filter: saturate(1); }
303
- 75% { filter: saturate(0); }
304
- 100% { filter: saturate(1); }
305
- }
306
- @keyframes pulse2 { /* for restarting */
307
- 0% { filter: saturate(1); }
308
- 25% { filter: saturate(4); }
309
- 50% { filter: saturate(1); }
310
- 75% { filter: saturate(0); }
311
- 100% { filter: saturate(1); }
300
+ @keyframes pulse08 {
301
+ 0% { filter: saturate(1); background-color: rgb(189, 46, 47, 0%); border-radius: 8px; }
302
+ 25% { filter: saturate(4); background-color: rgb(189, 46, 47, 25%); }
303
+ 50% { filter: saturate(1); background-color: rgb(189, 46, 47, 40%); }
304
+ 75% { filter: saturate(0); background-color: rgb(189, 46, 47, 25%); }
305
+ 100% { filter: saturate(1); background-color: rgb(189, 46, 47, 0%); border-radius: 8px;}
306
+ }
307
+ /* 0.2s 0.2s */
308
+ /* <-0.8s-> <-0.8s-> */
309
+ /* 4 4 4 */
310
+ /* 1 1 1 1 1 1 */
311
+ /* 0 0 */
312
+ @keyframes pulse20 {
313
+ 0% { filter: saturate(1); background-color: rgb(189, 46, 47, 0%); border-radius: 8px; }
314
+ 10% { filter: saturate(4); background-color: rgb(189, 46, 47, 25%); }
315
+ 20% { filter: saturate(1); background-color: rgb(189, 46, 47, 50%); }
316
+ 30% { filter: saturate(0); }
317
+ 40% { filter: saturate(1); }
318
+ 50% { filter: saturate(4); }
319
+ 60% { filter: saturate(1); }
320
+ 70% { filter: saturate(0); }
321
+ 80% { filter: saturate(1); background-color: rgb(189, 46, 47, 50%); }
322
+ 90% { filter: saturate(4); background-color: rgb(189, 46, 47, 25%); }
323
+ 100% { filter: saturate(1); background-color: rgb(189, 46, 47, 0%); border-radius: 8px; }
324
+ }
325
+ @keyframes pulse20border {
326
+ 0% { filter: saturate(1); background-color: rgb(189, 46, 47, 0%); border-radius: 8px; border: solid thin rgb(189, 46, 47, 0);}
327
+ 10% { filter: saturate(4); background-color: rgb(189, 46, 47, 25%); border: solid thick rgb(189, 46, 47, 100%); }
328
+ 20% { filter: saturate(1); background-color: rgb(189, 46, 47, 50%); }
329
+ 30% { filter: saturate(0); }
330
+ 40% { filter: saturate(1); }
331
+ 50% { filter: saturate(4); }
332
+ 60% { filter: saturate(1); }
333
+ 70% { filter: saturate(0); }
334
+ 80% { filter: saturate(1); background-color: rgb(189, 46, 47, 50%); }
335
+ 90% { filter: saturate(4); background-color: rgb(189, 46, 47, 25%); border: solid thick rgb(189, 46, 47, 100%); }
336
+ 100% { filter: saturate(1); background-color: rgb(189, 46, 47, 0%); border-radius: 8px; border: solid thin rgb(189, 46, 47, 0); }
312
337
  }
313
338
 
314
339
  #checkForUpdates, #downloadUpdates, #updateContainer md-elevated-button {
@@ -320,7 +345,7 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
320
345
  gap: 10px;
321
346
  }
322
347
  .pub {
323
- animation: pulse ease 0.8s 1;
348
+ animation: pulse08 ease 0.8s 1;
324
349
  --_label-text-weight: 800;
325
350
  --_selected-label-text-color: var(--md-sys-color-secondary);
326
351
  --md-filter-chip-selected-leading-icon-color: var(--md-sys-color-secondary);
@@ -1,12 +1,47 @@
1
1
  // Offer a websocket connection for testing, that skips the DHT entirely, and accepts publishing by pushing to subscribers over the websocket.
2
2
  import { WebSocketServer } from 'ws';
3
- import * as operator from '../public/javascripts/pubsub.js';
3
+ import { operators } from '../public/javascripts/pubsub.js';
4
4
 
5
5
  const sockets = {};
6
- operator.setReceiver((nodeId, id, envelope) => {
6
+ operators.setSender((nodeId, eventHandlerId, envelope) => new Promise((resolve, reject) => {
7
+ // Setup pubsub to receive events, by supplying a function that promises to send the event.
8
+ //
9
+ // When a client goes to the background, it will stay connected for at least a few seconds. An alert during that time will be transmitted in the usual way.
10
+ // If the client politely closes, our close handler will catch this, and call pubsub's deleteSubscriber(), which activates any sticky subs.
11
+ // An alert after that be transmitted by the sticky push sub, if any.
12
+ // We have a 20 second heartbeat to all clients: when someone has gone dark by being in the background, that will supply the cose.
13
+ // An alert after heartbeat expiration wil be transmitted by the sticky push sub, if any.
14
+ // But... Sending on a websocket that isn't listening will silently appear to succeed.
15
+ // (The protocol is designed that way so that operating systems can buffer the output, so any tcp ack failure could occur
16
+ // long after socket.send() returned.)
17
+ // Oy.
18
+ // If the OS suspends the client, say, 5 seconds after going into the background, an alert between 5 and 20 seconds would not be delivered!
19
+ // In order to prevent this, outgoing events send an ackTag string (in addition to the event envelope), which the client must send back.
20
+ // Here we set the event publisher to answer a Promise. If the sender acknowledges the event within ACK_TIME_MS, it resolves to undefined.
21
+ // But if there is no acknowledgement (or if the updated readyState after sending is not OPEN), then the promise is rejected,
22
+ // and pubsub can catch that and transmit by the sticky push sub, if any.
7
23
  const socket = sockets[nodeId];
8
- socket.send(JSON.stringify([id, envelope])); // We want an error if socket is gone, closed, etc.
9
- });
24
+ if (!socket) return;
25
+ const ACK_TIME_MS = 20e3;
26
+ const ackTag = nodeId + '-' + socket.sendCounter++;
27
+ const fail = reason => {
28
+ delete sockets[nodeId]; // Keep close() from removing any sticky sub.
29
+ delete operators[ackTag];
30
+ socket.terminate(); // close will be asynchronous.
31
+ reject(reason); // Reject now (before close), so that caller can catch this and push message to sticky sub, if any.
32
+ };
33
+ console.log(new Date(), 'socket send', socket.readyState, ackTag, eventHandlerId);
34
+ const timer = setTimeout(() => fail(`No acknowledgement for ${ackTag} in state ${socket.readyState} for handler ${eventHandlerId}.`), ACK_TIME_MS);
35
+ operators[ackTag] = () => {
36
+ console.log('ack', ackTag, 'from eventHandlerId', eventHandlerId);
37
+ clearTimeout(timer);
38
+ delete operators[ackTag];
39
+ resolve();
40
+ };
41
+ socket.send(JSON.stringify([eventHandlerId, ackTag, envelope])); // We want an error if socket is gone, closed, etc.
42
+ // State might not update until we attempt to actually send.
43
+ if (socket.readyState !== WebSocket.OPEN) fail(`Bad state send to ${ackTag}, state ${socket.readyState} for handler ${eventHandlerId}.`);
44
+ }));
10
45
 
11
46
  function heartbeat() {
12
47
  this.isAlive = true;
@@ -17,13 +52,15 @@ export function configureWebsocket(server) {
17
52
  wss.on('connection', (ws, req) => {
18
53
  const nodeTag = req.url.slice(1);
19
54
  console.log('Connected', nodeTag);
55
+ ws.sendCounter = 0;
20
56
  sockets[nodeTag] = ws;
21
57
 
22
58
  ws.on('message', async message => {
23
59
  try {
24
- const [id, methodName, ...rest] = JSON.parse(message);
25
- const result = await operator[methodName](...rest);
26
- if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify([id, result]));
60
+ const parsed = JSON.parse(message);
61
+ const [id, methodName, ...rest] = parsed;
62
+ const result = await operators[methodName](...rest);
63
+ if (id && (ws.readyState === WebSocket.OPEN)) ws.send(JSON.stringify([id, result]));
27
64
  } catch (error) {
28
65
  console.error(`Incomming ${error.message}: ${message}\n${error.stack}`);
29
66
  }
@@ -34,15 +71,19 @@ export function configureWebsocket(server) {
34
71
 
35
72
  ws.on('error', console.error);
36
73
  ws.on('close', () => {
37
- console.log('Disconnected', nodeTag);
74
+ console.log(new Date(), 'Disconnected', nodeTag);
75
+ // Guarded, so that we don't double delete, and remove an active sticky sub.
76
+ if (sockets[nodeTag]) operators.deleteSubscriber(nodeTag);
38
77
  delete sockets[nodeTag];
39
- operator.deleteSubscriber(nodeTag);
40
78
  });
41
79
  });
42
80
 
43
81
  const interval = setInterval(function ping() { // Keep-alive ping/pong on interval
44
82
  wss.clients.forEach(function each(ws) {
45
- if (!ws.isAlive) return ws.terminate();
83
+ if (!ws.isAlive) {
84
+ console.log('heartbeat failure');
85
+ return ws.terminate();
86
+ }
46
87
  if (ws.readyState == WebSocket.OPEN) {
47
88
  ws.isAlive = false;
48
89
  ws.ping();