@yz-social/civildefense.io 4.5.16 → 4.5.21
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.
- package/package.json +2 -1
- package/public/index.html +1 -0
- package/public/javascripts/agent.js +52 -35
- package/public/javascripts/alert.js +26 -10
- package/public/javascripts/hashtags.js +48 -29
- package/public/javascripts/main.js +3 -3
- package/public/javascripts/map.js +1 -1
- package/public/javascripts/p2pWebNetwork.js +11 -7
- package/public/javascripts/protocol.js +168 -0
- package/public/javascripts/pubsub.js +124 -0
- package/public/javascripts/s2.js +31 -40
- package/public/service-worker.js +20 -5
- package/public/stylesheets/style.css +15 -9
- package/server/app.js +8 -5
- package/server/websocket.js +46 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yz-social/civildefense.io",
|
|
3
3
|
"description": "Browser app to safely share live sightings on a map.",
|
|
4
|
-
"version": "4.5.
|
|
4
|
+
"version": "4.5.21",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"map",
|
|
7
7
|
"browser",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"pica": "^9.0.1",
|
|
30
30
|
"s2js": "^1.44.0",
|
|
31
31
|
"uuid": "^14.0.0",
|
|
32
|
+
"ws": "^8.21.3",
|
|
32
33
|
"yargs": "^18.0.0"
|
|
33
34
|
},
|
|
34
35
|
"overrides": {
|
package/public/index.html
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { localStorage } = globalThis;
|
|
1
|
+
const { localStorage, URL } = globalThis;
|
|
2
2
|
import { v4 as uuidv4 } from 'uuid';
|
|
3
3
|
import { minidenticonSvg } from 'minidenticons';
|
|
4
4
|
import { agentTopic, agentPersistKey } from './versions.js';
|
|
@@ -53,23 +53,30 @@ export class Agent {
|
|
|
53
53
|
const value = localStorage.getItem(this.localPersistKey(type, tag));
|
|
54
54
|
this.updateValue(value, scope, type, false); // Don't publish until we post.
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
|
|
57
|
+
// A map may show multiple regions, and we want to show consistent attribution metadata for alerts in each.
|
|
58
|
+
// Thus we have to keep track of each region where we have activity, and subscribe to changes in each such region for
|
|
59
|
+
// each mentioned avatar instance. See trackPublicChanges.
|
|
60
|
+
//
|
|
61
|
+
// For our agent(s), we also need to keep track of the current msgId so that we can kill it properly when we change value.
|
|
62
|
+
// This will be different in each tracked region.
|
|
63
|
+
//
|
|
64
|
+
trackedRegions = {}; // region code => {avatar, handle} last msgIds for this agent as known.
|
|
65
|
+
trackPublicChanges(region) { // Subscribe to changes for this agent instance's metdata in this region.
|
|
66
|
+
// We do this for for each Agent.ensure that appears for an alert or reply.
|
|
67
|
+
// and also for our own Agent.current when subscribing to a topic, so that we have the right killTag even if we have not published THIS session.
|
|
68
|
+
if (this.trackedRegions[region]) return; // Already subscribed.
|
|
62
69
|
networkPromise.then(contact => {
|
|
63
|
-
this.trackedRegions[region] =
|
|
70
|
+
this.trackedRegions[region] = {};
|
|
64
71
|
const owner = this.tag;
|
|
65
72
|
['handle', 'avatar'].forEach(type => {
|
|
66
73
|
const eventName = this.networkPersistKey(type);
|
|
67
|
-
contact.subscribe({eventName, region, owner, since: 'latest', handler: data => this.setPublicData({...data, type})});
|
|
74
|
+
contact.subscribe({eventName, region, owner, since: 'latest', handler: data => this.setPublicData({...data, type, region})});
|
|
68
75
|
});
|
|
69
76
|
});
|
|
70
77
|
}
|
|
71
|
-
async setPublicData(data) { // Subscription to public data has fired. Update value, but do not not re-publish.
|
|
72
|
-
let {payload, tag, type, topic, ts} = data;
|
|
78
|
+
async setPublicData(data) { // Subscription to public data has fired. Update value, but do not not re-publish (e.g., if our agent).
|
|
79
|
+
let {payload, tag, type, region, topic, ts} = data;
|
|
73
80
|
// WARNING: IF we chunkify avatars, and we use since:'all', then we need lock out asynchronous
|
|
74
81
|
// decoding of later timestamps, or of null payloads.
|
|
75
82
|
// if (payload && (type === 'avatar')) {
|
|
@@ -78,7 +85,8 @@ export class Agent {
|
|
|
78
85
|
// payload = dataURL;
|
|
79
86
|
// }
|
|
80
87
|
this.updateValue(payload, 'public', type, false);
|
|
81
|
-
if (
|
|
88
|
+
if (payload) this.trackedRegions[region][type] = tag;
|
|
89
|
+
else delete this.trackedRegions[region][type];
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
static agents = {}; // tag => Agent
|
|
@@ -93,12 +101,12 @@ export class Agent {
|
|
|
93
101
|
handle: {system: null, public: null, private: null, mixed: null},
|
|
94
102
|
avatar: {system: null, public: null, private: null, mixed: null}
|
|
95
103
|
};
|
|
96
|
-
publicMsgId = {}; // maps type => msgId for saved public data of this Agent instance.
|
|
97
104
|
getValue(scope, type) {
|
|
98
105
|
return this.values[type][scope];
|
|
99
106
|
}
|
|
100
107
|
updateValue(value, scope, type, pushPublic = true) { // Updates dependent elements, and if necessary, the mixed values/elements as well.
|
|
101
|
-
|
|
108
|
+
const match = this.values[type][scope] === value;
|
|
109
|
+
if (match) return null;
|
|
102
110
|
|
|
103
111
|
// Persist if private. For public, update locally but do not publish until this agent publishes an alert or reply.
|
|
104
112
|
if (scope === 'private') this.persistPrivate(value, type);
|
|
@@ -120,33 +128,41 @@ export class Agent {
|
|
|
120
128
|
if (value === null) localStorage.removeItem(key);
|
|
121
129
|
else localStorage.setItem(key, value);
|
|
122
130
|
}
|
|
123
|
-
async persistPublicMetadata(
|
|
124
|
-
|
|
131
|
+
async persistPublicMetadata() { // Publish handle and avatar.
|
|
132
|
+
// Used by Agent.current when we post an alert or reply.
|
|
133
|
+
// There could already be such a publication active, but republishing ensures that it is as fresh as
|
|
134
|
+
// the alert/reply (e.g., with respect to expirations).
|
|
125
135
|
await Promise.all(['handle', 'avatar'].map(type => this.persistPublic(this.getValue('public', type) || null, type)));
|
|
126
136
|
}
|
|
127
137
|
async persistPublic(value, type) { // Publish (and we will act on subscription).
|
|
138
|
+
// We persist to ALL our tracked regions, so that anyone in those regions will get the latest metadata.
|
|
139
|
+
// Note that we might be tracking regions A and B, while someone else is tracking B and C.
|
|
140
|
+
// The other user will get the new value through B, and that will be THE value displayed on their machine.
|
|
141
|
+
// BUT... if the other user tracks B and gets the new value, and then moves into region C and gets a
|
|
142
|
+
// stale value, they will see the stale value in both regions going forward in that session until we update in that region.
|
|
128
143
|
const eventName = this.networkPersistKey(type);
|
|
129
|
-
const region = this.currentRegion;
|
|
130
144
|
const owner = this.tag;
|
|
131
145
|
const contact = await networkPromise;
|
|
132
146
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
for (let region in this.trackedRegions) {
|
|
148
|
+
// First kill previous, if any.
|
|
149
|
+
// Publish doesn't know whether subscribers will be 'since' all or 'latest', so it must retain all unkilled.
|
|
150
|
+
// Thus if we only kill the last one when there is no value,
|
|
151
|
+
// a subscribe since:latest will produce the PREVIOUS value -- the last unkilled one.
|
|
137
152
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (killTag) await contact.publish({eventName, region, owner, killTag, payload: null});
|
|
153
|
+
const killTag = this.trackedRegions[region][type];
|
|
154
|
+
if (killTag) await contact.publish({eventName, region, owner, killTag, payload: null});
|
|
141
155
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
156
|
+
if (value) {
|
|
157
|
+
let payload = value;
|
|
158
|
+
// Our downsampling is such that we do not need to chunkify.
|
|
159
|
+
// if (type === 'avatar') {
|
|
160
|
+
// const blob = await P2PWebNetwork.dataURL2blob(value);
|
|
161
|
+
// payload = (await contact.chunkifyBlob({blob, region})).topic;
|
|
162
|
+
// }
|
|
163
|
+
contact.publish({eventName, region, owner, payload});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
150
166
|
}
|
|
151
167
|
|
|
152
168
|
// We represent handles and avatars by inserting stuff into given elements.
|
|
@@ -275,14 +291,16 @@ export class Agent {
|
|
|
275
291
|
static current = null;
|
|
276
292
|
static tag = null;
|
|
277
293
|
static identity = null;
|
|
294
|
+
// Keep user separate between dht=1 or empty (Axona) vs dht=0 or -1 (no Axona, for testing).
|
|
295
|
+
static usertagKey = `usertag${parseInt(new URL(location).searchParams.get('dht')) < 1 ? '0' : ''}`;
|
|
278
296
|
static switchUser(tag, identity) { // Set/persist/ensure the current user, return Agent
|
|
279
297
|
this.tag = tag; // Before the ensure().
|
|
280
298
|
this.identity = P2PWebNetwork.currentPublishIdentity = identity;
|
|
281
|
-
localStorage.setItem(
|
|
299
|
+
localStorage.setItem(this.usertagKey, this.tag);
|
|
282
300
|
return this.current = this.ensure({tag, identity});
|
|
283
301
|
}
|
|
284
302
|
static async initialize() { // Initialize what the agent needs from the about screen
|
|
285
|
-
let tag = localStorage.getItem(
|
|
303
|
+
let tag = localStorage.getItem(this.usertagKey);
|
|
286
304
|
const persistAs = tag || uuidv4(); // Give it SOMETHING to persistAs.
|
|
287
305
|
const myIdentity = await P2PWebNetwork.createAuthorIdentity({persistAs});
|
|
288
306
|
if (tag !== persistAs) { // Fix up persistence by moving it to where it need to go.
|
|
@@ -328,4 +346,3 @@ export class Agent {
|
|
|
328
346
|
});
|
|
329
347
|
}
|
|
330
348
|
}
|
|
331
|
-
|
|
@@ -7,8 +7,8 @@ import { consume } from './display.js';
|
|
|
7
7
|
import { Hashtags } from './hashtags.js';
|
|
8
8
|
import { Agent } from './agent.js';
|
|
9
9
|
import { Conversation, Reply } from './conversation.js';
|
|
10
|
-
import { alertTopic, topicRegion, topicCell } from './versions.js';
|
|
11
|
-
import { getContainingCells, getSubdivision,
|
|
10
|
+
import { alertTopic, topicRegion, topicCell, cellHex } from './versions.js';
|
|
11
|
+
import { getContainingCells, getSubdivision, findCoverCellsByMinMaxLatLng } from './s2.js';
|
|
12
12
|
const { localStorage, getComputedStyle, URL, URLSearchParams, domtoimage } = globalThis;
|
|
13
13
|
|
|
14
14
|
|
|
@@ -112,15 +112,22 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
112
112
|
const center = map.getCenter();
|
|
113
113
|
const bounds = map.getBounds();
|
|
114
114
|
const northEast = bounds.getNorthEast();
|
|
115
|
-
const
|
|
115
|
+
const southWest = bounds.getSouthWest();
|
|
116
|
+
const zoom = map.getZoom();
|
|
117
|
+
const newCells = findCoverCellsByMinMaxLatLng({
|
|
118
|
+
full: zoom <= map.options.minZoom,
|
|
119
|
+
minLat: southWest.lat,
|
|
120
|
+
maxLat: northEast.lat,
|
|
121
|
+
minLng: southWest.lng,
|
|
122
|
+
maxLng: northEast.lng
|
|
123
|
+
});
|
|
116
124
|
if (!newCells) return null;
|
|
117
|
-
const region = P2PWebNetwork.regionCode(center.lat, center.lng);
|
|
118
125
|
const newKeys = {};
|
|
126
|
+
|
|
119
127
|
newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => {
|
|
120
128
|
const eventName = alertTopic(cell, hash);
|
|
121
129
|
newKeys[eventName] = this.subscriptions[eventName] || 0;
|
|
122
130
|
}));
|
|
123
|
-
Agent.current?.trackPublicChanges(region);
|
|
124
131
|
// Record a zoomed-out cell id in case next session does not have geolocation services.
|
|
125
132
|
let level9Cell = getContainingCells(center.lat, center.lng)[9];
|
|
126
133
|
if (level9Cell !== this.lastLevel9Cell) localStorage.setItem('level9Cell', this.lastLevel9Cell = level9Cell);
|
|
@@ -162,6 +169,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
162
169
|
}
|
|
163
170
|
});
|
|
164
171
|
const region = topicRegion(key);
|
|
172
|
+
Agent.current?.trackPublicChanges(region); // Background. No need to await.
|
|
165
173
|
await contact.subscribe({eventName: key, region, handler}).then(() => throttleMS && P2PWebNetwork.delay(throttleMS));
|
|
166
174
|
};
|
|
167
175
|
console.log('updating subscriptions', {newKeys, oldKeys});
|
|
@@ -176,7 +184,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
176
184
|
// Publish an alert to all applicable eventNames, canceling as required. Promises tag (msgId).
|
|
177
185
|
static async publish({lat, lng,
|
|
178
186
|
originalPosting = undefined,
|
|
179
|
-
hashtag = Hashtags.getPublish(),
|
|
187
|
+
hashtag = Hashtags.getPublish(true),
|
|
180
188
|
payload = {lat, lng, originalPosting}, // If payload is null (cancels subject), lat & lng are still used to generate eventNames.
|
|
181
189
|
cancel = undefined, // First unpublish the specified data, if any. Complicated default.
|
|
182
190
|
issuedTime = Date.now(), subject,
|
|
@@ -222,6 +230,9 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
222
230
|
for (const cell of cells) {
|
|
223
231
|
const eventName = alertTopic(cell, hashtag);
|
|
224
232
|
if (payload) {
|
|
233
|
+
// The Axona message will be {hashtag, issuedTime, payload:{lat, lng, originalPosting}}
|
|
234
|
+
// and when combined with the publisher's authorId will be unique to this user/time/hashtag,
|
|
235
|
+
// and yet the same for each of the individual publications at the different s2 scales.
|
|
225
236
|
const msgId = await contact.publish({eventName, region, payload, issuedTime, hashtag, ...rest});
|
|
226
237
|
if (subject && subject !== msgId) throw new Error(`msgId is drifting: ${subject} => ${msgId}`);
|
|
227
238
|
subject = msgId;
|
|
@@ -303,6 +314,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
303
314
|
}
|
|
304
315
|
initialize({payload, hashtag, subject, agent, issuedTime, ...rest}) { // Set up the marker for a newly received alert.
|
|
305
316
|
if (!payload) return null; // Do not cache. E.g., Received a delete event without the initial creation.
|
|
317
|
+
if (!Hashtags.isSubscribed(hashtag)) return null; // A subscribed event may have been in flight while unsubscribing.
|
|
306
318
|
const icon = this.constructor.makeIcon(hashtag);
|
|
307
319
|
const {lat, lng, originalPosting} = payload;
|
|
308
320
|
const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
|
|
@@ -513,7 +525,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
513
525
|
payload = {message: payload, file};
|
|
514
526
|
}
|
|
515
527
|
await contact.publish({eventName: subject, region, payload}); // Publish the new reply.
|
|
516
|
-
Agent.current.persistPublicMetadata(
|
|
528
|
+
Agent.current.persistPublicMetadata();
|
|
517
529
|
}
|
|
518
530
|
deleteReply(replyElement) {
|
|
519
531
|
resetInactivityTimer();
|
|
@@ -543,7 +555,11 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
543
555
|
const icon = new URL('./images/civil-defense-192.png', location.href).href;
|
|
544
556
|
const url = getShareableURL(alert, [hashtag]).href; // For opening page when it has been closed.
|
|
545
557
|
const data = {lat, lng, url};
|
|
546
|
-
|
|
558
|
+
// It appears that on 8/14/26:
|
|
559
|
+
// Safari ignores tag/renotify, and ALWAYS tells the user and displays each notification separately, without consolidating by tag.
|
|
560
|
+
// Chrome ignores renotify, and ALWAYS consolidates by tag, replacing old body with new, and NEVER renotifies the user (for the same tag).
|
|
561
|
+
// So... we could get uniform behavior by skipping the tag, but for now we'll try using it as intended, in case the browsers ever start to comply.
|
|
562
|
+
const options = {icon, timestamp, tag: alert, body, data, renotify: true};
|
|
547
563
|
console.log('showNotification', hashtag, options);
|
|
548
564
|
registration.showNotification(hashtag, options);
|
|
549
565
|
});
|
|
@@ -556,8 +572,8 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
556
572
|
const formatReply = ({subject, payload, ...rest}) => {
|
|
557
573
|
const {message = payload, file, name} = payload || {}; // Message text converts recognized urls to A/V players or links.
|
|
558
574
|
let text = message
|
|
559
|
-
.replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u8|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}"></audio>`) // show audio urls as players
|
|
560
|
-
.replace(/https?:\/\/\S+\.(mp4|mov|webm)$/ig, url => `<video controls src="${url}"></video>`) // show video urls as players
|
|
575
|
+
.replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u8|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}" crossorigin="anonymous"></audio>`) // show audio urls as players
|
|
576
|
+
.replace(/https?:\/\/\S+\.(mp4|mov|webm)$/ig, url => `<video controls src="${url}" crossorigin="anonymous"></video>`) // show video urls as players
|
|
561
577
|
.replace(/(?<!")https?:\/\/\S+/g, url => `<a href="${url}" target="yz.sidebar">${url}</a>`); // show urls as links
|
|
562
578
|
let attachment = '';
|
|
563
579
|
if (file?.startsWith?.('data:image')) attachment = `<a href="${file}" download="${name}"><img class="attachment" src="${file}"></img></a>`;
|
|
@@ -6,6 +6,7 @@ import { Alert } from './alert.js';
|
|
|
6
6
|
import { resetInactivityTimer, clickTip } from './main.js';
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
const help = `🆘 ${Int`help`}`;
|
|
9
10
|
let allKnownHashtags = JSON.parse(localStorage.getItem('allKnownHashtags') || `[
|
|
10
11
|
"🍰 cake",
|
|
11
12
|
"🎸 classic rock",
|
|
@@ -15,7 +16,7 @@ let allKnownHashtags = JSON.parse(localStorage.getItem('allKnownHashtags') || `[
|
|
|
15
16
|
"🎧 edm",
|
|
16
17
|
"🔥 fire",
|
|
17
18
|
"🌊 flood",
|
|
18
|
-
"
|
|
19
|
+
"${help}",
|
|
19
20
|
"🎤 hiphop",
|
|
20
21
|
"🧊 ice",
|
|
21
22
|
"🎷 jazz",
|
|
@@ -81,14 +82,19 @@ export const Hashtags = {
|
|
|
81
82
|
getSubscribe() { // Return a list of the hashtags to which the user intendeds to subscribe.
|
|
82
83
|
return this.getAll().filter(tag => this.hashtags[tag]);
|
|
83
84
|
},
|
|
85
|
+
isSubscribed(key) {
|
|
86
|
+
return this.hashtags[key];
|
|
87
|
+
},
|
|
84
88
|
isPublish(key) {
|
|
85
89
|
return this.hashtags[key] === 'pub';
|
|
86
90
|
},
|
|
87
|
-
|
|
91
|
+
backupPublisher: false,
|
|
92
|
+
getPublish(force = false) { // Return the one hashtag to which the user intends to publish.
|
|
93
|
+
// If force and no publisher, setPublisher to backup and return it.
|
|
88
94
|
let pub = this.getAll().find(key => this.isPublish(key));
|
|
89
|
-
if (!pub) {
|
|
90
|
-
pub = this.
|
|
91
|
-
this.
|
|
95
|
+
if (!pub && force) {
|
|
96
|
+
pub = this.backupPublisher || help;
|
|
97
|
+
this.setPublish(pub);
|
|
92
98
|
this.onchange({highlightPublish: true});
|
|
93
99
|
}
|
|
94
100
|
return pub;
|
|
@@ -115,8 +121,10 @@ export const Hashtags = {
|
|
|
115
121
|
if (redisplaySubscribers) this.resetSubscriberDisplay();
|
|
116
122
|
localStorage.setItem('hashtags', JSON.stringify(this.hashtags));
|
|
117
123
|
if (resetSubscriptions) {
|
|
118
|
-
|
|
124
|
+
// We destroy unsubscribed markers right away, because we don't want the user to have to wait and wonder why they're still displayed.
|
|
125
|
+
// If there are alerts in flight, they will be rejected by Alert initialize because we will have already turned off the sub.
|
|
119
126
|
Object.values(Alert.items).forEach(wrapper => this.hashtags[wrapper.hashtag] || wrapper.destroy());
|
|
127
|
+
Alert.updateSubscriptions();
|
|
120
128
|
}
|
|
121
129
|
},
|
|
122
130
|
chipset: document.body.querySelector('.watching-hashtags'), // Element containing the user's chips.
|
|
@@ -126,7 +134,7 @@ export const Hashtags = {
|
|
|
126
134
|
${active === 'pub' ? 'class="pub"' : ''}
|
|
127
135
|
${active ? ' selected' : ''}
|
|
128
136
|
>${this.firstEmoji(label) ? '' : this.identicon(label, 'selected-icon')}
|
|
129
|
-
<md-icon-button slot="remove-trailing-icon"
|
|
137
|
+
<md-icon-button slot="remove-trailing-icon"><md-icon class="material-icons"></md-icon></md-icon-button>
|
|
130
138
|
</md-filter-chip>`;
|
|
131
139
|
},
|
|
132
140
|
|
|
@@ -234,8 +242,10 @@ export const Hashtags = {
|
|
|
234
242
|
li.id = `tag-option-${i}`;
|
|
235
243
|
li.setAttribute('role', 'option');
|
|
236
244
|
li.innerHTML = this.formatPubtag(highlight(item, matchString), item);
|
|
237
|
-
li.
|
|
238
|
-
|
|
245
|
+
li.onclick = event => {
|
|
246
|
+
// pointerdown would fire before the text field's blur event,
|
|
247
|
+
// so we would not have to delay that. But then we would not
|
|
248
|
+
// scroll properly by touch drag.
|
|
239
249
|
event.preventDefault();
|
|
240
250
|
event.stopPropagation();
|
|
241
251
|
this.selectValue(item);
|
|
@@ -295,7 +305,6 @@ export const Hashtags = {
|
|
|
295
305
|
});
|
|
296
306
|
this.chipset.insertAdjacentHTML("afterbegin", // Chip to add a new hashtag.
|
|
297
307
|
`<div class="combobox">
|
|
298
|
-
<ul class="combobox-listbox hidden" id="knownTagsListbox" role="listbox"></ul>
|
|
299
308
|
<md-filled-text-field class="newtag"
|
|
300
309
|
aria-expanded="false"
|
|
301
310
|
aria-controls="knownTagsListbox"
|
|
@@ -307,7 +316,7 @@ export const Hashtags = {
|
|
|
307
316
|
// I've tried also supplying a datalist, e.g., to supply the mobile keyboard completions, but
|
|
308
317
|
// I have not been able to get it to work.
|
|
309
318
|
const newtag = this.newtag = this.chipset.querySelector('.newtag');
|
|
310
|
-
const listbox = this.listbox =
|
|
319
|
+
const listbox = this.listbox = document.querySelector('.combobox-listbox');
|
|
311
320
|
clickTip(newtag, Int`Add a new topic for which the map should show any alerts.`, event => { // Focusing "add topic".
|
|
312
321
|
event.stopPropagation();
|
|
313
322
|
Alert.closePopup();
|
|
@@ -353,8 +362,10 @@ export const Hashtags = {
|
|
|
353
362
|
}
|
|
354
363
|
};
|
|
355
364
|
newtag.oninput = () => this.renderSelector(newtag.value);
|
|
356
|
-
|
|
357
|
-
//
|
|
365
|
+
// When we click on the listbox, the browser will first blur newtag, and then
|
|
366
|
+
// we would not get the click! So here we delay closing a bit.
|
|
367
|
+
newtag.onblur = () => setTimeout(() => this.closeSelector(), 200);
|
|
368
|
+
newtag.onchange = () => this.acceptTag();
|
|
358
369
|
},
|
|
359
370
|
remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
|
|
360
371
|
delete this.hashtags[chip.label];
|
|
@@ -364,25 +375,28 @@ export const Hashtags = {
|
|
|
364
375
|
toggleChip(chip) { // Switch whether the topic is or is not subscribed.
|
|
365
376
|
// Now selected => hashtags[label] becomes 'pub' (selected and the publisher) and clear old pub
|
|
366
377
|
// NOT now selected => hashtags[label] becomes false (through mechanism as follows)
|
|
367
|
-
// but if publisher => set alt publisher if possible, else remember as
|
|
378
|
+
// but if publisher => set alt publisher if possible, else remember as backupPublisher
|
|
368
379
|
const label = chip.label;
|
|
369
380
|
|
|
370
381
|
// chip.selected is new state, after clicking.
|
|
371
382
|
if (chip.selected) return this.setPublish(label); // Become publisher, clearing old publisher.
|
|
372
383
|
|
|
384
|
+
// Not selected:
|
|
385
|
+
|
|
373
386
|
// If we're not publisher, just clear. But don't go through getPublish, as that can have side effects.
|
|
374
387
|
if (this.hashtags[label] !== 'pub') return this.hashtags[label] = false;
|
|
375
388
|
|
|
376
|
-
//
|
|
377
|
-
|
|
378
|
-
if (subs.length > 1) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
389
|
+
// Also clear, but...
|
|
390
|
+
const subs = this.getSubscribe();
|
|
391
|
+
if (subs.length > 1) { // Find and set alternative publisher if possible.
|
|
392
|
+
const pubIndex = subs.indexOf(label);
|
|
393
|
+
const index = (pubIndex + 1) % subs.length;
|
|
394
|
+
this.setPublish(subs[index]);
|
|
395
|
+
} else {
|
|
396
|
+
// No alternative available. Clear it, but remember for use by getPublish.
|
|
397
|
+
// It will stay .pub styled while toggled, until anything toggles on.
|
|
398
|
+
this.backupPublisher = label;
|
|
382
399
|
}
|
|
383
|
-
|
|
384
|
-
// No alternative. Clear it, but remember for use by getPublish.
|
|
385
|
-
this.lastRemainingPublisher = label;
|
|
386
400
|
return this.hashtags[label] = false;
|
|
387
401
|
},
|
|
388
402
|
getChip(label) { // Handy for scripting, but not otherwise used in app.
|
|
@@ -391,13 +405,18 @@ export const Hashtags = {
|
|
|
391
405
|
}
|
|
392
406
|
return null;
|
|
393
407
|
},
|
|
394
|
-
setPublish(newTag
|
|
395
|
-
|
|
396
|
-
if
|
|
408
|
+
setPublish(newTag) { // Make this topic be the one to be used when we next publish an alert.
|
|
409
|
+
// newTag will be marked for publishing (in this.hashtags and element style)
|
|
410
|
+
// Old publish tag (if any) will be set back to merely be subscribed (in same)
|
|
411
|
+
let oldTag = this.getPublish();
|
|
412
|
+
const backup = this.backupPublisher;
|
|
413
|
+
if (oldTag) this.hashtags[oldTag] = true; // true (instead of 'pub')
|
|
414
|
+
else if (backup) oldTag = backup;
|
|
415
|
+
this.backupPublisher = false;
|
|
397
416
|
this.hashtags[newTag] = 'pub';
|
|
398
417
|
for (const chip of this.chipset.children) {
|
|
399
|
-
if (chip.label ===
|
|
400
|
-
else if (chip.label ===
|
|
418
|
+
if (chip.label === newTag) chip.classList.add('pub');
|
|
419
|
+
else if (chip.label === oldTag) chip.classList.remove('pub');
|
|
401
420
|
}
|
|
402
421
|
return oldTag;
|
|
403
422
|
}
|
|
@@ -406,5 +425,5 @@ globalThis.Hashtags = Hashtags; // for debugging
|
|
|
406
425
|
|
|
407
426
|
// Populate hashtags data and display.
|
|
408
427
|
// First the persisted/default data:
|
|
409
|
-
const persisted = JSON.parse(localStorage.getItem('hashtags') || `{"🍰 ${Int`cake`}": true, "
|
|
428
|
+
const persisted = JSON.parse(localStorage.getItem('hashtags') || `{"🍰 ${Int`cake`}": true, "${help}": "pub"}`);
|
|
410
429
|
Object.entries(persisted).forEach(([tag, active]) => Hashtags.add(tag, active, false));
|
|
@@ -237,12 +237,12 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
|
|
|
237
237
|
if (level9Cell) { // Zoomed out near where we last where, but not too exact for security.
|
|
238
238
|
zoom = 12;
|
|
239
239
|
[lat, lng] = getPointInCell(BigInt(level9Cell));
|
|
240
|
-
} else {
|
|
240
|
+
} else { // If the user doesn't want to turn on geolocation, we whould certainly not use ipinfo.io or the like.
|
|
241
241
|
zoom = 13;
|
|
242
242
|
[lat, lng] = [37.7749, -122.4194]; // San Fransisco
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
|
-
console.log('initializeGeolocation updateLocation');
|
|
245
|
+
//console.log('initializeGeolocation updateLocation');
|
|
246
246
|
updateLocation(lat, lng, zoom, positionLabel);
|
|
247
247
|
if (!subscribeOneShot) return;
|
|
248
248
|
subscribeOneShot = false;
|
|
@@ -258,7 +258,7 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
|
|
|
258
258
|
positionWatch = geolocation.watchPosition(
|
|
259
259
|
position => {
|
|
260
260
|
const {latitude, longitude} = position.coords;
|
|
261
|
-
console.log('Location update.', map ? 'Map exists.' : 'Will create map.', subscribeOneShot ? 'Will subscribe fresh.' : 'Has subscriptions.');
|
|
261
|
+
//console.log('Location update.', map ? 'Map exists.' : 'Will create map.', subscribeOneShot ? 'Will subscribe fresh.' : 'Has subscriptions.');
|
|
262
262
|
initMap(latitude, longitude);
|
|
263
263
|
}, error => {
|
|
264
264
|
geolocation.clearWatch(positionWatch);
|
|
@@ -153,7 +153,7 @@ export function initMap(lat, lng, zoom, positionLabel) { // Set up appropriate z
|
|
|
153
153
|
if (document.getElementById('map').querySelector('.leaflet-popup')) return; // Ignore clicks with popup open.
|
|
154
154
|
const { lat, lng } = e.latlng;
|
|
155
155
|
Alert.openPopup(await Alert.publish({lat, lng}));
|
|
156
|
-
Agent.current.persistPublicMetadata(
|
|
156
|
+
Agent.current.persistPublicMetadata();
|
|
157
157
|
});
|
|
158
158
|
if (document.querySelector('.leaflet-control-zoom')) { // Not present in mobile
|
|
159
159
|
tooltip('.leaflet-control-zoom-in', Int`Zoom in to show more detail in the map.`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
-
import { connect,
|
|
3
|
-
import { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } from '
|
|
2
|
+
import { connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } from './protocol.js';
|
|
3
|
+
import { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } from './protocol.js';
|
|
4
4
|
|
|
5
5
|
if (!Uint8Array.prototype.toBase64) { // NodeJS < 24
|
|
6
6
|
Object.defineProperty(Uint8Array.prototype, 'toBase64', {
|
|
@@ -28,20 +28,24 @@ export class P2PWebNetwork {
|
|
|
28
28
|
region = this.sessionRegion,
|
|
29
29
|
bridgeUrl = (globalThis.location && new URL(globalThis.location).searchParams.get('bridge')) ||
|
|
30
30
|
globalThis.process?.env.BRIDGE_URL ||
|
|
31
|
+
(parseInt(new URL(globalThis.location || 'file://').searchParams.get('dht')) <= 0 && // fixme remove when we host bridges
|
|
32
|
+
globalThis.location?.origin.replace(/^http/, 'ws')) ||
|
|
31
33
|
'wss://bridge.axona.net',
|
|
32
34
|
} = {}) {
|
|
33
35
|
// Promise a ready-to-use network peer.
|
|
34
36
|
region = await region;
|
|
35
37
|
|
|
38
|
+
const network = new this();
|
|
39
|
+
network.resetStatePromises();
|
|
40
|
+
|
|
36
41
|
const { peer, nodeIdentity, transport, status, disconnect } = await connect({
|
|
37
42
|
bridge: bridgeUrl,
|
|
38
43
|
location: region,
|
|
44
|
+
onDisconnect: network.detached,
|
|
39
45
|
author: false
|
|
40
46
|
});
|
|
41
|
-
|
|
42
|
-
const network = new this();
|
|
43
47
|
Object.assign(network, {infoLogger, debugLogger, disconnector: disconnect, transport, nodeIdentity, peer});
|
|
44
|
-
|
|
48
|
+
|
|
45
49
|
network.info(`Created network node for kernel ${this.kernelVersion} region 0x${this.regionCode(region.lat, region.lng).toString(16)}.`);
|
|
46
50
|
peer.onError(error => {
|
|
47
51
|
network.info(`error: ${error.message || error}`);
|
|
@@ -189,7 +193,7 @@ export class P2PWebNetwork {
|
|
|
189
193
|
};
|
|
190
194
|
await this.peer.sub(topic, callback, {since});
|
|
191
195
|
} else {
|
|
192
|
-
this.peer.unsub(topic, {});
|
|
196
|
+
await this.peer.unsub(topic, {});
|
|
193
197
|
}
|
|
194
198
|
}
|
|
195
199
|
static currentPublishIdentity = null;
|
|
@@ -217,7 +221,7 @@ export class P2PWebNetwork {
|
|
|
217
221
|
static regionCode(lat, lng) { // Answer containing region code.
|
|
218
222
|
return geoCellId(lat, lng);
|
|
219
223
|
}
|
|
220
|
-
static regionCenter(regionCode) {
|
|
224
|
+
static regionCenter(regionCode) { // {lat, lng} of center of regionCode
|
|
221
225
|
return geoCellCenter(regionCode);
|
|
222
226
|
}
|
|
223
227
|
static delay(ms, result) { // Promise result after ms milliseconds.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// If ?dht=0, use a websocket to the server instead of Axona.
|
|
2
|
+
const { TextEncoder, TextDecoder, BigInt, URL, WebSocket, Buffer } = globalThis;
|
|
3
|
+
|
|
4
|
+
let connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION, stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes;
|
|
5
|
+
|
|
6
|
+
// dht 1 -> Axona (default)
|
|
7
|
+
// dht 0 -> server
|
|
8
|
+
// dht -1 -> in-memory on client only
|
|
9
|
+
const dht = parseInt(globalThis.process ? globalThis.process.env.DHT : new URL(globalThis.location).searchParams.get('dht'));
|
|
10
|
+
|
|
11
|
+
if (dht < 1) {
|
|
12
|
+
|
|
13
|
+
const { v4:uuidv4 } = await import('uuid');
|
|
14
|
+
const { getContainingCells, getPointInCell } = await import('./s2.js');
|
|
15
|
+
const { cellHex } = await import('./versions.js');
|
|
16
|
+
const operator = await import('./pubsub.js');
|
|
17
|
+
|
|
18
|
+
WIRE_VERSION = 'SERVER';
|
|
19
|
+
KERNEL_VERSION = `${WIRE_VERSION}.1.0`;
|
|
20
|
+
createAuthorIdentity = ({
|
|
21
|
+
persistAs, store = {
|
|
22
|
+
get: (key) => globalThis.localStorage.getItem(key),
|
|
23
|
+
set: (key, value) => globalThis.localStorage.setItem(key, value)
|
|
24
|
+
}}) => {
|
|
25
|
+
let tag;
|
|
26
|
+
if (persistAs) {
|
|
27
|
+
tag = store.get(persistAs);
|
|
28
|
+
if (!tag) {
|
|
29
|
+
tag = uuidv4();
|
|
30
|
+
store.set(persistAs, tag);
|
|
31
|
+
} else if (tag.includes('pubkey')) {
|
|
32
|
+
tag = JSON.parse(tag).pubkey; // if it is a real dump, as for alert-bot.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {authorId: tag};
|
|
36
|
+
};
|
|
37
|
+
geoCellId = (lat, lng) => {
|
|
38
|
+
const cells = getContainingCells(lat, lng);
|
|
39
|
+
const hex = cellHex(cells[0]);
|
|
40
|
+
const sliced = hex.slice(0, 2);
|
|
41
|
+
return parseInt(sliced, 16); // The worst way to do this.
|
|
42
|
+
};
|
|
43
|
+
geoCellCenter = regionCode => { // Not right. See getPointInCell comments.
|
|
44
|
+
const expanded = regionCode.toString(16).padStart(2, '0').padEnd(16, '0');
|
|
45
|
+
const cellid = BigInt('0x' + expanded);
|
|
46
|
+
const [lat, lng] = getPointInCell(cellid);
|
|
47
|
+
return {lat, lng};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
bytesToString = u8 => {
|
|
51
|
+
return new TextDecoder().decode(u8);
|
|
52
|
+
};
|
|
53
|
+
stringToBytes = (str) => {
|
|
54
|
+
return new TextEncoder().encode(str);
|
|
55
|
+
};
|
|
56
|
+
const hasBuffer = typeof Buffer !== 'undefined';
|
|
57
|
+
function bytesToB64(u8) {
|
|
58
|
+
if (hasBuffer) return Buffer.from(u8).toString('base64');
|
|
59
|
+
let s = ''; const CH = 0x8000;
|
|
60
|
+
for (let i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH));
|
|
61
|
+
return btoa(s);
|
|
62
|
+
}
|
|
63
|
+
function b64ToBytes(b64) {
|
|
64
|
+
if (hasBuffer) return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
65
|
+
const s = atob(b64); const u8 = new Uint8Array(s.length);
|
|
66
|
+
for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i);
|
|
67
|
+
return u8;
|
|
68
|
+
}
|
|
69
|
+
publishChunkedBytes = (peer, u8, {name, mime}) => {
|
|
70
|
+
const str = bytesToB64(u8);
|
|
71
|
+
return {topic: {name, mime, str}};
|
|
72
|
+
};
|
|
73
|
+
receiveChunkedBytes = (peer, {name, mime, str}, options) => {
|
|
74
|
+
const u8 = b64ToBytes(str);
|
|
75
|
+
return {bytes: u8, name, mime, msgIds: []};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
connect = async ({bridge, location, onDisconnect}) => {
|
|
79
|
+
// We always call location as {lat, lng}
|
|
80
|
+
// We always call it with author:false
|
|
81
|
+
const {lat, lng} = location;
|
|
82
|
+
const region = geoCellId(lat, lng);
|
|
83
|
+
const nodeTag = region.toString(16).padStart(2, '0') + uuidv4();
|
|
84
|
+
const nodeIdentity = {id: nodeTag};
|
|
85
|
+
const handlers = {}; // guid => handler tag
|
|
86
|
+
const inFlight = {};
|
|
87
|
+
let disconnect, transport; // But do not call P2PWebNetwork.ice!!!
|
|
88
|
+
const send = await new Promise(resolve => {
|
|
89
|
+
if (dht === 0) {
|
|
90
|
+
const url = `${bridge}/${nodeTag}`;
|
|
91
|
+
const socket = transport = new WebSocket(url);
|
|
92
|
+
socket.onmessage = event => {
|
|
93
|
+
const [tag, ...rest] = JSON.parse(event.data);
|
|
94
|
+
const subHandler = handlers[tag];
|
|
95
|
+
const inFlightResolver = inFlight[tag];
|
|
96
|
+
if ((!subHandler && !inFlightResolver) ||
|
|
97
|
+
((typeof(subHandler) !== 'function') && (typeof(inFlightResolver) !== 'function')))
|
|
98
|
+
console.log({tag, rest, subHandler, inFlightResolver, handlers, inFlight});
|
|
99
|
+
if (subHandler) return subHandler(...rest);
|
|
100
|
+
delete inFlight[tag];
|
|
101
|
+
return inFlightResolver?.(...rest);
|
|
102
|
+
};
|
|
103
|
+
socket.onopen = () => {
|
|
104
|
+
if (socket.readyState !== WebSocket.OPEN) return; // You would think that can't happen, but...
|
|
105
|
+
resolve((...rest) => { // send()
|
|
106
|
+
const tag = uuidv4();
|
|
107
|
+
const {promise, resolve} = Promise.withResolvers();
|
|
108
|
+
inFlight[tag] = resolve;
|
|
109
|
+
socket.send(JSON.stringify([tag, ...rest]));
|
|
110
|
+
return promise;
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
// onerror is of no help, as the event is generic.
|
|
114
|
+
socket.onclose = event => {
|
|
115
|
+
console.warn('websocket close', event.code, event.wasClean, event.reason);
|
|
116
|
+
onDisconnect();
|
|
117
|
+
};
|
|
118
|
+
disconnect = () => socket.close();
|
|
119
|
+
} else {
|
|
120
|
+
disconnect = () => null;
|
|
121
|
+
operator.setReceiver((nodeTag, id, ...rest) => handlers[id](...rest));
|
|
122
|
+
resolve((methodName, ...rest) => operator[methodName](...rest)); // send()
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const peer = {
|
|
127
|
+
onError() {},
|
|
128
|
+
onLog() {},
|
|
129
|
+
health() {
|
|
130
|
+
return {peers: [], axonRoles: []};
|
|
131
|
+
},
|
|
132
|
+
async leave() {
|
|
133
|
+
await send('deleteSubscriber', nodeTag);
|
|
134
|
+
disconnect();
|
|
135
|
+
},
|
|
136
|
+
async sub(topic, handler, options) {
|
|
137
|
+
const result = await send('subscribe', topic, nodeTag, options);
|
|
138
|
+
handlers[result.id] = handler;
|
|
139
|
+
return result;
|
|
140
|
+
},
|
|
141
|
+
async unsub(topic, options) {
|
|
142
|
+
const result = await send('unsubscribe', topic, nodeTag, options);
|
|
143
|
+
delete handlers[result.id];
|
|
144
|
+
return result;
|
|
145
|
+
},
|
|
146
|
+
async pub(topic, message, options) {
|
|
147
|
+
return send('publish', topic, message, options);
|
|
148
|
+
},
|
|
149
|
+
kill(topic, msgId, options) {
|
|
150
|
+
return send('unpublish', topic, msgId, options);
|
|
151
|
+
},
|
|
152
|
+
host() {},
|
|
153
|
+
unhost() {}
|
|
154
|
+
};
|
|
155
|
+
const status = {peers: 0, ms: 0}; // fixme ms
|
|
156
|
+
return { peer, nodeIdentity, transport, status, disconnect };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
} else {
|
|
160
|
+
const protocol = await import('@axona/protocol');
|
|
161
|
+
const std = await import('@axona/protocol/std');
|
|
162
|
+
({ connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } = protocol);
|
|
163
|
+
({ stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } = std);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export { connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION };
|
|
167
|
+
export { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes }
|
|
168
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// In memory pubsub, for either client-only testing, or server-websocket testing
|
|
2
|
+
const { v4:uuidv4 } = await import('uuid');
|
|
3
|
+
const { TextEncoder, crypto, Buffer } = globalThis;
|
|
4
|
+
|
|
5
|
+
function setBucket(collection, type, topicId, subject, value) { // Set value in the collection.
|
|
6
|
+
const bucket = collection[type][topicId] ||= {};
|
|
7
|
+
bucket[subject] = value;
|
|
8
|
+
}
|
|
9
|
+
function removeBucket(collection, type, topicId, subject) { // Return the value and stop storing it.
|
|
10
|
+
const bucket = collection[type][topicId];
|
|
11
|
+
if (!bucket) return null;
|
|
12
|
+
const value = bucket[subject];
|
|
13
|
+
delete bucket[subject];
|
|
14
|
+
if (!Object.keys(bucket).length) delete collection[type][topicId];
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SUBSCRIPTION_TIMEOUT = 0; // No need, because we run deleteSubscriber on disconnect.
|
|
19
|
+
const PUBLISH_TIMEOUT = 24 * 60 * 60e3; // Delete after 24 hours.
|
|
20
|
+
const timeouts = {pub: {}, sub: {}};
|
|
21
|
+
function expire(type, topicId, subject, remover, timeout) { // Cancellably schedule remover() to fire at timeout.
|
|
22
|
+
if (!timeout) return;
|
|
23
|
+
setBucket(timeouts, type, topicId, subject, setTimeout(remover, timeout));
|
|
24
|
+
}
|
|
25
|
+
function cancel(type, topicId, subject) { // Cancel a sheduled expiration.
|
|
26
|
+
clearTimeout(removeBucket(timeouts, type, topicId, subject));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// pub maps eventName => {[subject]: storageItem, ...}, where subject is the message id. Entries purged after PUBLISH_TIMEOUT.
|
|
30
|
+
// sub maps eventName => {[subject]: ws, ...}, where subject is the subscriber id. Entries purged after SUBSCRIPTION_TIMEOUT.
|
|
31
|
+
const data = {pub: {}, sub: {}};
|
|
32
|
+
function getDataValues(type, topicId) { // For all subjects
|
|
33
|
+
return Object.values(data[type][topicId] || {});
|
|
34
|
+
}
|
|
35
|
+
function getDataEntries(type, topicId) {
|
|
36
|
+
return Object.entries(data[type][topicId] || {});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deleteSub(topicId, subject) {
|
|
40
|
+
return removeBucket(data, 'sub', topicId, subject);
|
|
41
|
+
}
|
|
42
|
+
function normalizeTopic({name, region, owner, write = 'open'} = {}) {
|
|
43
|
+
if (typeof(region) === 'string') region = parseInt(region);;
|
|
44
|
+
return {name, region, owner, write};
|
|
45
|
+
}
|
|
46
|
+
function deriveTopicId(topic) {
|
|
47
|
+
return JSON.stringify(normalizeTopic(topic)); // No need to hash in this implementation.
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let invoke;
|
|
51
|
+
export function setReceiver(receiver) {
|
|
52
|
+
invoke = receiver;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function subscribe(topicName, nodeTag, {since = 'all'}) {
|
|
56
|
+
// Axona allows multiple handlers on the same topic, but we don't use that in civildefense, and do not implement it here.
|
|
57
|
+
const topicId = deriveTopicId(topicName);
|
|
58
|
+
const id = uuidv4();
|
|
59
|
+
cancel('sub', topicId, id);
|
|
60
|
+
expire('sub', topicId, id, () => deleteSub(topicId, id), SUBSCRIPTION_TIMEOUT);
|
|
61
|
+
setBucket(data, 'sub', topicId, nodeTag, id);
|
|
62
|
+
if (since) setTimeout(() => { // invoke handler on any sticky data, but only after we have told client the subscription id.
|
|
63
|
+
let lastEnvelope = null, lastTime = 0;
|
|
64
|
+
for (const envelope of getDataValues('pub', topicId)) {
|
|
65
|
+
switch (since) {
|
|
66
|
+
case 'all':
|
|
67
|
+
invoke(nodeTag, id, envelope);
|
|
68
|
+
break;
|
|
69
|
+
case 'latest':
|
|
70
|
+
if (envelope.ts > lastTime) {
|
|
71
|
+
lastTime = envelope.ts;
|
|
72
|
+
lastEnvelope = envelope;
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
default: // Must be a timestamp
|
|
76
|
+
if (envelope.ts === since) invoke(nodeTag, id, envelope);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (lastEnvelope) invoke(nodeTag, id, lastEnvelope);
|
|
80
|
+
}, 100);
|
|
81
|
+
return {topicName, topicId, id};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function unsubscribe(topic, nodeTag, options) {
|
|
85
|
+
const topicId = deriveTopicId(topic);
|
|
86
|
+
cancel('sub', topicId, nodeTag);
|
|
87
|
+
const id = deleteSub(topicId, nodeTag);
|
|
88
|
+
return {ok: true, id}; // Axona doesn't return the id(s) of the subscription(s), but it is convenient for us to do so.
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function deleteSubscriber(nodeTag) {
|
|
92
|
+
for (const topicId in data.sub) {
|
|
93
|
+
const keySubs = data.sub[topicId];
|
|
94
|
+
for (const [subject, value] of Object.entries(keySubs)) {
|
|
95
|
+
if (nodeTag === subject) deleteSub(topicId, subject, keySubs);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hasBuffer = typeof Buffer !== 'undefined';
|
|
101
|
+
let toHex = hasBuffer ? u8 => Buffer.from(u8).toString('hex') : u8 => u8.toHex();
|
|
102
|
+
export async function publish(topic, message, {signWith}) {
|
|
103
|
+
const topicId = deriveTopicId(topic);
|
|
104
|
+
const signerPubkey = signWith?.authorId || undefined;
|
|
105
|
+
const payload = JSON.stringify({message, publisher: signerPubkey});
|
|
106
|
+
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload));
|
|
107
|
+
const msgId = toHex(new Uint8Array(hash));
|
|
108
|
+
const envelope = {msgId, topic, ts: Date.now(), message, signerPubkey};
|
|
109
|
+
for (const [nodeTag, id] of getDataEntries('sub', topicId)) invoke(nodeTag, id, envelope);
|
|
110
|
+
setBucket(data, 'pub', topicId, msgId, envelope);
|
|
111
|
+
expire('pub', topicId, msgId, () => removeBucket(data, 'pub', topicId, msgId), PUBLISH_TIMEOUT);
|
|
112
|
+
return msgId;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function unpublish(topic, msgId, {signWith}) {
|
|
116
|
+
const topicId = deriveTopicId(topic);
|
|
117
|
+
cancel('pub', topicId, msgId);
|
|
118
|
+
const envelope = removeBucket(data, 'pub', topicId, msgId);
|
|
119
|
+
if (!envelope) return {ok: false}; // we didn't have it.
|
|
120
|
+
envelope.deleted = true;
|
|
121
|
+
envelope.message = null;
|
|
122
|
+
for (const [nodeTag, id] of getDataEntries('sub', topicId)) invoke(nodeTag, id, envelope);
|
|
123
|
+
return {ok: true};
|
|
124
|
+
}
|
package/public/javascripts/s2.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { s2 } from 's2js';
|
|
1
|
+
import { s2, s1, r1 } from 's2js';
|
|
2
2
|
const { cellid, LatLng, Point, Cell, Cap, RegionCoverer } = s2;
|
|
3
3
|
import { cellHex } from './versions.js';
|
|
4
4
|
const { BigInt } = globalThis;
|
|
@@ -12,22 +12,30 @@ const { BigInt } = globalThis;
|
|
|
12
12
|
// Meanwhile as the user changes the area being shown, we subscribe to whatever cells we need in order to cover the display
|
|
13
13
|
// area without overlapping cells.
|
|
14
14
|
|
|
15
|
-
export const MIN_LEVEL =
|
|
15
|
+
export const MIN_LEVEL = 3; // Corresponds to the top level Axona regions.
|
|
16
16
|
const MAX_S2_LEVEL = 30; // The leaf level that Cell.fromPoint operates at.
|
|
17
17
|
const MAX_MAP_LEVEL = 17; // The max level that findCoverCellsByCenterAndRadius will use on our maps.
|
|
18
18
|
|
|
19
19
|
const EARTH_RADIUS_METERS = 6371e3;
|
|
20
20
|
|
|
21
21
|
export function getPointInCell(cellId) { // answer [lat, lng] in degrees from a BigInt
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return [lat, lng];
|
|
22
|
+
// CAUTION: This is intended for s2 level 3 or finer, and won't always work symmetrically for our top-level regions.
|
|
23
|
+
// e.g. getContainingCells(...getPointInCell( cell for 0x47 )][0] is 0x46!
|
|
24
|
+
let center = s2.cellid.latLng(cellId);
|
|
25
|
+
return [s1.angle.degrees(center.lat), s1.angle.degrees(center.lng)];
|
|
27
26
|
}
|
|
28
27
|
|
|
28
|
+
function getCellSubdivision(cell) {
|
|
29
|
+
return cellid.children(cell);
|
|
30
|
+
}
|
|
31
|
+
function getCellLevel(cell) {
|
|
32
|
+
return cellid.level(cell);
|
|
33
|
+
}
|
|
34
|
+
function getFace(cell) {
|
|
35
|
+
return cellid.face(cell);
|
|
36
|
+
}
|
|
29
37
|
export function getSubdivision(hexString) {
|
|
30
|
-
return
|
|
38
|
+
return getCellSubdivision(BigInt('0x' + hexString)).map(childCell => cellHex(childCell));
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
// Return a list of the cell ids that contain the point.
|
|
@@ -43,39 +51,22 @@ export function getContainingCells(lat, lng) {
|
|
|
43
51
|
return cells.slice(MIN_LEVEL, MAX_MAP_LEVEL + 1); // We can only make use between Axona region size and the smallest region our maps subscribe to.
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
const point = Point.fromLatLng(LatLng.fromDegrees(pointLat, pointLng));
|
|
51
|
-
const distanceAngle = center.distance(point);
|
|
52
|
-
const interestRadiusMeters = distanceAngle * EARTH_RADIUS_METERS;
|
|
53
|
-
return findCoverCellsByCenterAndRadius(centerLat, centerLng, interestRadiusMeters);
|
|
54
|
-
}
|
|
54
|
+
export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, full = false,
|
|
55
|
+
options:{minLevel = MIN_LEVEL, maxLevel = MAX_MAP_LEVEL, maxCells = 12} = {}}) {
|
|
56
|
+
// Return a list-like object of cell ids that cover the specified range.
|
|
57
|
+
// The lat/lng won't work well for the full map, so an exact full map can be requested, overriding that lat/lng.
|
|
55
58
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
+
// There are a lot of ways that seem like they do this, but it is very easy to find something that works for a few cases,
|
|
60
|
+
// but misses cells in some circumstances, so be wary about rewriting this.
|
|
61
|
+
const lo = s2.LatLng.fromDegrees(minLat, Math.max(minLng, -180));
|
|
62
|
+
const hi = s2.LatLng.fromDegrees(maxLat, Math.max(maxLng, 190));
|
|
59
63
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
let levelForRadius = MAX_S2_LEVEL;
|
|
65
|
-
const radiusAngle = radius / EARTH_RADIUS_METERS;
|
|
66
|
-
if (radiusAngle > 0) {
|
|
67
|
-
const deriv = 2 * Math.SQRT2 / 3;
|
|
68
|
-
levelForRadius = Math.floor(Math.log2(deriv / radiusAngle));
|
|
69
|
-
if (levelForRadius > MAX_S2_LEVEL) levelForRadius = MAX_S2_LEVEL;
|
|
70
|
-
if (levelForRadius < 0) levelForRadius = 0;
|
|
71
|
-
}
|
|
72
|
-
return levelForRadius;
|
|
73
|
-
};
|
|
74
|
-
const levelForRadius = findLevel(interestRadiusMeters); // - 1; // as seen in cellUnionBound: go one level bigger
|
|
64
|
+
const rect = full ? s2.Rect.fullRect() : new s2.Rect(
|
|
65
|
+
new r1.Interval(lo.lat, hi.lat),
|
|
66
|
+
new r1.Interval(lo.lng, hi.lng)
|
|
67
|
+
);
|
|
75
68
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
const rc = new RegionCoverer({ minLevel, maxLevel, maxCells: 9 }); // Will exceed maxCells as needed to obey minLevel.
|
|
79
|
-
const r = Cap.fromCenterAngle(point, interestRadiusMeters / EARTH_RADIUS_METERS);
|
|
80
|
-
return rc.covering(r);
|
|
69
|
+
const coverer = new RegionCoverer({minLevel, maxLevel, maxCells});
|
|
70
|
+
return coverer.covering(rect); // a CellUnion — array-like of bigint cell IDs, already normalized/minimal
|
|
81
71
|
}
|
|
72
|
+
|
package/public/service-worker.js
CHANGED
|
@@ -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 = '4.5.
|
|
3
|
+
const serviceVersion = '4.5.21';
|
|
4
4
|
|
|
5
5
|
const cacheList = [ // The files we need.
|
|
6
6
|
"/",
|
|
@@ -21,6 +21,8 @@ const cacheList = [ // The files we need.
|
|
|
21
21
|
"javascripts/translations.js",
|
|
22
22
|
"javascripts/service-manager.js",
|
|
23
23
|
"javascripts/p2pWebNetwork.js",
|
|
24
|
+
"javascripts/protocol.js",
|
|
25
|
+
"javascripts/pubsub.js",
|
|
24
26
|
|
|
25
27
|
"stylesheets/style.css",
|
|
26
28
|
|
|
@@ -34,6 +36,7 @@ const cacheList = [ // The files we need.
|
|
|
34
36
|
|
|
35
37
|
"axona-protocol/src/index.js",
|
|
36
38
|
"axona-protocol/src/errors.js",
|
|
39
|
+
"axona-protocol/src/connect.js",
|
|
37
40
|
"axona-protocol/src/bridgeDirectory.js",
|
|
38
41
|
"axona-protocol/src/contracts/Transport.js",
|
|
39
42
|
"axona-protocol/src/contracts/DHT.js",
|
|
@@ -49,6 +52,18 @@ const cacheList = [ // The files we need.
|
|
|
49
52
|
"axona-protocol/src/dht/Subscription.js",
|
|
50
53
|
"axona-protocol/src/pubsub/AxonaManager.js",
|
|
51
54
|
"axona-protocol/src/pubsub/authorClass.js",
|
|
55
|
+
"axona-protocol/src/pubsub/ids.js",
|
|
56
|
+
"axona-protocol/src/pubsub/dispatch.js",
|
|
57
|
+
"axona-protocol/src/pubsub/durability.js",
|
|
58
|
+
"axona-protocol/src/pubsub/rootClaim.js",
|
|
59
|
+
"axona-protocol/src/pubsub/constants.js",
|
|
60
|
+
"axona-protocol/src/pubsub/topicStore.js",
|
|
61
|
+
"axona-protocol/src/pubsub/rootElection.js",
|
|
62
|
+
"axona-protocol/src/pubsub/syncEngine.js",
|
|
63
|
+
"axona-protocol/src/pubsub/repairPlane.js",
|
|
64
|
+
"axona-protocol/src/pubsub/wireHandlers.js",
|
|
65
|
+
"axona-protocol/src/pubsub/writeFlight.js",
|
|
66
|
+
"axona-protocol/src/pubsub/ackProof.js",
|
|
52
67
|
"axona-protocol/src/pubsub/kill.js",
|
|
53
68
|
"axona-protocol/src/pubsub/touch.js",
|
|
54
69
|
"axona-protocol/src/pubsub/post.js",
|
|
@@ -166,20 +181,20 @@ self.addEventListener('fetch', event => {
|
|
|
166
181
|
async function cacheSource(version, event) { // Cache source in the given version.
|
|
167
182
|
console.log(`service-worker ${serviceVersion} is caching source in cache ${version}.`);
|
|
168
183
|
const cache = await caches.open(version);
|
|
169
|
-
|
|
170
|
-
|
|
184
|
+
await Promise.all(cacheList.map(file => cache.add(file)
|
|
185
|
+
.catch(error => console.error(file + ' ' + error.message))));
|
|
171
186
|
|
|
172
187
|
await Promise.all([
|
|
173
188
|
// These are referenced within material web, but missing. Turns out we don't need them,
|
|
174
189
|
// but let's cache empty responses to keep the console cleaner.
|
|
175
|
-
"https://esm.run/npm/lit@3.3.3/+esm",
|
|
176
190
|
"https://esm.run/npm/tslib@2.8.1/+esm",
|
|
191
|
+
"https://esm.run/npm/lit@3.3.3/+esm",
|
|
177
192
|
"https://esm.run/npm/lit@3.3.3/static-html.js/+esm",
|
|
178
193
|
"https://esm.run/npm/lit@3.3.3/decorators.js/+esm",
|
|
179
194
|
"https://esm.run/npm/lit@3.3.3/directives/style-map.js/+esm",
|
|
180
195
|
"https://esm.run/npm/lit@3.3.3/directives/class-map.js/+esm",
|
|
181
196
|
"https://esm.run/npm/lit@3.3.3/directives/when.js/+esm",
|
|
182
|
-
"https://esm.run/npm/lit@3.3.
|
|
197
|
+
"https://esm.run/npm/lit@3.3.3/directives/live.js/+esm",
|
|
183
198
|
].map(url => cache.put(new Request(url),
|
|
184
199
|
new Response("", {headers: { "Content-Type": "text/javascript" }}))));
|
|
185
200
|
return version;
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
--md-sys-shape-corner-full: 12px;
|
|
17
17
|
|
|
18
18
|
--about-image-size: 80px;
|
|
19
|
+
--combobox-minWidth: 125px;
|
|
19
20
|
|
|
20
21
|
/* --md-sys-color-tertiary-container: blue; */
|
|
21
22
|
/* --md-sys-color-background: yellow; */
|
|
@@ -196,22 +197,26 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
|
|
|
196
197
|
.combobox {
|
|
197
198
|
position: relative;
|
|
198
199
|
--textFieldHeight: 34px;
|
|
199
|
-
--minWidth: 125px;
|
|
200
200
|
}
|
|
201
201
|
.combobox-listbox {
|
|
202
202
|
padding: 5px;
|
|
203
203
|
position: absolute;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
max-height:
|
|
207
|
-
width: fit-content;
|
|
208
|
-
min-width: var(--minWidth);
|
|
204
|
+
right: 5px;
|
|
205
|
+
bottom: 55px;
|
|
206
|
+
max-height: 45dvh; /* leaving room for portrait keyboard. landscape keyboard has no room at all *\/ */
|
|
209
207
|
overflow-y: auto;
|
|
210
|
-
|
|
208
|
+
width: fit-content;
|
|
209
|
+
min-width: var(--combobox-minWidth);
|
|
210
|
+
z-index: 1000;
|
|
211
211
|
background: rgba(255, 255, 255, 0.95);
|
|
212
212
|
border-top-left-radius: 12px;
|
|
213
213
|
border-top-right-radius: 12px;
|
|
214
214
|
}
|
|
215
|
+
@media (orientation: landscape) and (pointer: coarse) {
|
|
216
|
+
.combobox-listbox {
|
|
217
|
+
display: none !important;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
215
220
|
.combobox-option {
|
|
216
221
|
font-size: 0.95rem;
|
|
217
222
|
color: var(--md-sys-color-primary);
|
|
@@ -238,12 +243,13 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
|
|
|
238
243
|
background: #B8C2D7;
|
|
239
244
|
}
|
|
240
245
|
.watching-hashtags md-filled-text-field.newtag {
|
|
246
|
+
anchor-name: --combobox-newtag;
|
|
241
247
|
--md-filled-field-top-space: 0px;
|
|
242
248
|
--md-filled-field-bottom-space: 0px;
|
|
243
249
|
--md-filled-text-field-leading-space: 8px;
|
|
244
250
|
--md-filled-text-field-trailing-space: 0px;
|
|
245
|
-
height:
|
|
246
|
-
width: var(--minWidth);
|
|
251
|
+
height: 34px;
|
|
252
|
+
width: var(--combobox-minWidth);
|
|
247
253
|
}
|
|
248
254
|
.alert-marker {
|
|
249
255
|
text-shadow: 8px -3px 10px rgb(0 0 0 / 60%);
|
package/server/app.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from 'node:process';
|
|
3
|
-
import { exec } from 'node:child_process';
|
|
4
3
|
import {cpus, availableParallelism } from 'node:os';
|
|
5
4
|
import cluster from 'node:cluster';
|
|
6
|
-
import http from 'node:http';
|
|
7
|
-
import express from 'express';
|
|
8
|
-
import logger from 'morgan';
|
|
9
5
|
import yargs from 'yargs';
|
|
10
6
|
import { hideBin } from 'yargs/helpers';
|
|
11
7
|
import { resolve } from './dirname.js';
|
|
@@ -60,10 +56,17 @@ const debug = argv.verbose && ((...rest) => console.log(new Date(), ...rest));
|
|
|
60
56
|
function delay(ms = argv.spacing * 1e3) { return new Promise(resolve => setTimeout(resolve, ms)); }
|
|
61
57
|
|
|
62
58
|
if (cluster.isPrimary) { // Parent process with portal webserver through which clienta can bootstrap
|
|
59
|
+
const http = await import('node:http');
|
|
60
|
+
const express = (await import('express')).default;
|
|
61
|
+
const logger = (await import('morgan')).default;
|
|
62
|
+
const { configureWebsocket } = await import('./websocket.js');
|
|
63
|
+
|
|
63
64
|
const port = parseInt((new URL(argv.baseURL)).port || '80');
|
|
64
65
|
process.title = 'yz.social';
|
|
65
66
|
const app = express();
|
|
66
67
|
app.use(logger(':date[iso] :status :method :url :res[content-length] - :response-time ms'));
|
|
68
|
+
const server = http.createServer(app);
|
|
69
|
+
configureWebsocket(server);
|
|
67
70
|
|
|
68
71
|
// if (argv.announce) { // The default is to not announce.
|
|
69
72
|
// let announce = null;
|
|
@@ -98,7 +101,7 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
|
|
|
98
101
|
extensions: ['js'] // Some dependencies refer to .js files as relative pathnames, with the .js missing.
|
|
99
102
|
}));
|
|
100
103
|
|
|
101
|
-
|
|
104
|
+
server.listen(port);
|
|
102
105
|
log(`Listening on ${port} and starting ${argv.nPortals} nodes on ${logicalCores} ${cpus()[0].model} logical cores.`);
|
|
103
106
|
for (let i = 0; i < argv.nPortals; i++) {
|
|
104
107
|
cluster.fork();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Offer a websocket connection for testing, that skips the DHT entirely, and accepts publishing by pushing to subscribers over the websocket.
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import * as operator from '../public/javascripts/pubsub.js';
|
|
4
|
+
|
|
5
|
+
const sockets = {};
|
|
6
|
+
operator.setReceiver((nodeId, id, envelope) => {
|
|
7
|
+
const socket = sockets[nodeId];
|
|
8
|
+
socket.send(JSON.stringify([id, envelope]));
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
function heartbeat() {
|
|
12
|
+
this.isAlive = true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function configureWebsocket(server) {
|
|
16
|
+
const wss = new WebSocketServer({ server });
|
|
17
|
+
wss.on('connection', (ws, req) => {
|
|
18
|
+
const nodeTag = req.url.slice(1);
|
|
19
|
+
console.log('Connected', nodeTag);
|
|
20
|
+
sockets[nodeTag] = ws;
|
|
21
|
+
|
|
22
|
+
ws.on('message', async message => {
|
|
23
|
+
const [id, methodName, ...rest] = JSON.parse(message);
|
|
24
|
+
const result = await operator[methodName](...rest);
|
|
25
|
+
ws.send(JSON.stringify([id, result]));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
ws.isAlive = true;
|
|
29
|
+
ws.on('pong', heartbeat);
|
|
30
|
+
|
|
31
|
+
ws.on('error', console.error);
|
|
32
|
+
ws.on('close', () => {
|
|
33
|
+
console.log('Disconnected', nodeTag);
|
|
34
|
+
operator.deleteSubscriber(nodeTag);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const interval = setInterval(function ping() { // Keep-alive ping/pong on interval
|
|
39
|
+
wss.clients.forEach(function each(ws) {
|
|
40
|
+
if (!ws.isAlive) return ws.terminate();
|
|
41
|
+
ws.isAlive = false;
|
|
42
|
+
ws.ping();
|
|
43
|
+
return null;
|
|
44
|
+
});
|
|
45
|
+
}, 30e3);
|
|
46
|
+
}
|