@yz-social/civildefense.io 4.4.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.
Files changed (77) hide show
  1. package/README.md +68 -0
  2. package/announce.js +24 -0
  3. package/docs/YZ-Brief.pdf +0 -0
  4. package/index.js +2 -0
  5. package/movie/camera.jpg +0 -0
  6. package/movie/movie.js +151 -0
  7. package/movie/script.js +272 -0
  8. package/movie/test.js +2 -0
  9. package/nginx/nginx.conf +83 -0
  10. package/nginx/yz.social +81 -0
  11. package/package.json +34 -0
  12. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -0
  13. package/public/about/CivilDefense.mp4 +0 -0
  14. package/public/about/In case of Nazis, use CivilDefense.io.png +0 -0
  15. package/public/about/broadcast.png +0 -0
  16. package/public/about/civil-defense.png +0 -0
  17. package/public/about/conversation-and-image-high.png +0 -0
  18. package/public/about/conversation-and-image.png +0 -0
  19. package/public/about/en.html +157 -0
  20. package/public/about/es.html +142 -0
  21. package/public/about/flood-fire-ice-high.png +0 -0
  22. package/public/about/flood-fire-ice.png +0 -0
  23. package/public/about/hero-high.png +0 -0
  24. package/public/about/hero.png +0 -0
  25. package/public/about/index.html +8 -0
  26. package/public/about/nazis.png +0 -0
  27. package/public/about/script.js +69 -0
  28. package/public/about/streaming-radio-high.png +0 -0
  29. package/public/about/streaming-radio.png +0 -0
  30. package/public/about/style.css +306 -0
  31. package/public/control-safe-rectangle.html +40 -0
  32. package/public/favicon.ico +0 -0
  33. package/public/images/Achtung.png +0 -0
  34. package/public/images/YZ Owl.png +0 -0
  35. package/public/images/civil-defense-122.png +0 -0
  36. package/public/images/civil-defense-192.png +0 -0
  37. package/public/images/civil-defense-240.png +0 -0
  38. package/public/images/civil-defense-512.png +0 -0
  39. package/public/images/civil-defense.png +0 -0
  40. package/public/images/hero-small.png +0 -0
  41. package/public/images/qr-scan.svg +2 -0
  42. package/public/images/qr.png +0 -0
  43. package/public/images/qr.svg +155 -0
  44. package/public/images/recenter.svg +5 -0
  45. package/public/images/share.png +0 -0
  46. package/public/images/share.svg +2 -0
  47. package/public/index.html +170 -0
  48. package/public/javascripts/agent.js +324 -0
  49. package/public/javascripts/display.js +25 -0
  50. package/public/javascripts/hashtags.js +205 -0
  51. package/public/javascripts/main.js +394 -0
  52. package/public/javascripts/map.js +725 -0
  53. package/public/javascripts/p2pWebNetwork.js +245 -0
  54. package/public/javascripts/s2.js +77 -0
  55. package/public/javascripts/scripting.js +112 -0
  56. package/public/javascripts/service-manager.js +149 -0
  57. package/public/javascripts/translations.js +139 -0
  58. package/public/javascripts/versions.js +23 -0
  59. package/public/manifest.json +25 -0
  60. package/public/owl.ico +0 -0
  61. package/public/platformer.html +52 -0
  62. package/public/robots.txt +6 -0
  63. package/public/service-worker.js +218 -0
  64. package/public/stylesheets/style.css +442 -0
  65. package/routes/index.js +123 -0
  66. package/server/app.js +116 -0
  67. package/server/bridge.js +397 -0
  68. package/server/dirname.js +15 -0
  69. package/server/getLocation.js +18 -0
  70. package/server/identity.js +111 -0
  71. package/server/location.json +12 -0
  72. package/spec/axonSpec.gratuitousNameChangeForSignal +246 -0
  73. package/spec/axonSpec.js +280 -0
  74. package/spec/axonSpec.jsRemoveThePartAfterJS +252 -0
  75. package/spec/civildefenseSpec.js +61 -0
  76. package/spec/pubsubSpec.js +128 -0
  77. package/spec/support/jasmine.mjs +14 -0
@@ -0,0 +1,246 @@
1
+ /*
2
+ FIXME: Things that either don't pass, or require undocumented workarounds.
3
+ TODO: Things that ought to be dealt with at some point, but can be deferred until later.
4
+ CURRENTLY:
5
+ - This passes (with the FIXMEs in place) in main/3.8.0
6
+ - This usually fails to receive some of the expected subscription callbacks in testnet/4.3.2, and thus hangs.
7
+
8
+ To RUN, e.g., in NodeJS:
9
+ - You may need to adjust the path to webTransport. See the first TODO entry.
10
+ - To switch between them, don't forget to change the wss url a few lines down from hehre.
11
+ - Have jasmine or the like installed and initialized, and then e.g., npx jasmine spec/axonSpec.js.
12
+
13
+ It is worth running this several times. It sometimes works once, and then fails or has enormous connect times on another run.
14
+
15
+ The logging tells the story.
16
+ Alice, Bob, and Carol are Node instances. (Defined below, followed by the Jasmine test suite.)
17
+ Alice and Bob will subscribe and publish to an open/since:'all' topic.
18
+ Carol will join and subscribe between the previous subscriptions and their publications.
19
+ After publications, Bob will politely disconnect, and then restart and subscribe again to get same results.
20
+ Carol will restart without an explicit disconnect, and subscribe again after publications.
21
+ David will join and subscribe after publications.
22
+ */
23
+ const { describe, it, expect, beforeAll, afterAll, BigInt } = globalThis;
24
+ import { AxonaPeer, AxonaDomain, NeuronNode, createNodeIdentity, createAuthorIdentity, regionCenter, geoCellId, geoCellCenter, KERNEL_VERSION} from '@axona/protocol';
25
+
26
+ // TODO: What is the right way to use Axona web transport. It doesn't seem to provide either a functioning export nor declare its dependencies.
27
+ import { webTransport } from '../../axona-protocol/src/transport/web/index.js';
28
+ globalThis.RTCPeerConnection ||= await import('node-datachannel/polyfill').then(ndc => ndc.RTCPeerConnection);
29
+
30
+ class Node { // Stuff we have to do every time. TODO: build something like this into Axona.
31
+ static version = KERNEL_VERSION;
32
+ log(...rest) {
33
+ console.log(new Date(), this.label, this.transportIdentity.id.slice(0, 10), ...rest);
34
+ }
35
+ static async create({location, transportIdentity, bridgeUrl = 'wss://testnet.axona.net',
36
+ label = 'network', store, authorIdentity,
37
+ synapseCount = 4, timeoutMs = 10e3, ...rest} = {}) {
38
+ // Promise a ready-to-use network peer.
39
+
40
+ const start = Date.now();
41
+ // Complex location/identity behavior: Must pass either identity or location {lat/lng} (either can be a promise).
42
+ if (!transportIdentity) location ||= this.canonicalizeRegion(await location);
43
+ transportIdentity ||= createNodeIdentity(location);
44
+ transportIdentity = await transportIdentity;
45
+ location ||= transportIdentity.region; // TODO: don't use the same term 'region' in different ways.
46
+
47
+ if (typeof(authorIdentity) === 'string') // TODO: this is pretty awkward.
48
+ authorIdentity = createAuthorIdentity({persistAs: label, store: {get() { return authorIdentity; }}});
49
+ authorIdentity ||= createAuthorIdentity({persistAs: label, store});
50
+ authorIdentity = await authorIdentity;
51
+
52
+ // FIXME: sometimes fails with "UpgradeRequiredError: bridge closed socket before handshake completed"
53
+ const transport = webTransport({bridgeUrl, identity: transportIdentity});
54
+ const node = new NeuronNode({lat: location.lat, lng: location.lng, id: BigInt('0x' + transportIdentity.id)});
55
+ node.transport = transport; // TODO: pass in to constructor?
56
+ const domain = new AxonaDomain({ k: 20 }); // TODO: can't this be defaulted in AxonaPeer?
57
+ const peer = new AxonaPeer({domain, node, identity: transportIdentity, transport});
58
+
59
+ const self = new this();
60
+ Object.assign(self, {transportIdentity, transport, node, peer, label, authorIdentity, ...rest});
61
+ self.log('created with kernel version', this.version, 'in', (Date.now() - start).toLocaleString(), 'ms.');
62
+ await self.connect({synapseCount, timeoutMs});
63
+ return self;
64
+ }
65
+ async connect({synapseCount = 4, timeoutMs = 10e3} = {}) {
66
+ // Returned promise resolves when ready for use.
67
+ // TODO: Currently, one cannot disconnect() and then later connect() - one is likely to get TransportError: bridge socket closed before open.
68
+ // So as it stands there's not really any point in this being a separate method from create().
69
+ const start = Date.now();
70
+ await this.transport.start(this.transportIdentity.id);
71
+ await this.peer.join();
72
+ // TODO: This is required to get good results. Shouldn't it be built in to join()?
73
+ const t0 = Date.now();
74
+ while (Date.now() - t0 < timeoutMs) {
75
+ const size = this.synaptomeSize;
76
+ if (size >= synapseCount) break;
77
+ await this.constructor.delay(200);
78
+ }
79
+ const elapsed = Date.now() - start;
80
+ // FIXME: most of the time, this completes in under two seconds. But not infrequently, it is much more.
81
+ // I've see it take 30 seconds -- even when there are nearby nodes standing by.
82
+ this.log('connected to', this.synaptomeSize, 'nodes in', elapsed.toLocaleString(), elapsed < 2e3 ? 'ms.' : 'ms!!!!!!!!!!!!!!!!' );
83
+ return this;
84
+ }
85
+ async disconnect() { // Politely close network connection.
86
+ const start = Date.now();
87
+ const health = this.health();
88
+ await this.peer.leave();
89
+ this.log(`disconnected with ${health.peers.length} connections and ${health.axonRoles.length} axons in ${(Date.now() - start).toLocaleString()} ms.`);
90
+ await this.peer.stop();
91
+ }
92
+ async subscribe({eventName, region, owner, since = 'all', handler}) { // Assign handler for eventName, or remove any handler if falsy.
93
+ const topic = {region, name: eventName};
94
+ if (owner) topic.owner = owner;
95
+ if (handler) {
96
+ const callback = async ({...rest}) => handler({receiver: this, ...rest}); // Add receiver to envelope.
97
+ await this.peer.sub(topic, callback, {since});
98
+ } else {
99
+ this.peer.unsub(topic, {});
100
+ }
101
+ }
102
+ async publish({eventName, region, owner, signWith = this.authorIdentity, killTag, message}) {
103
+ // Publish data to subscribers of eventName.
104
+ const topic = {region, name: eventName};
105
+ if (owner) topic.owner = owner;
106
+ const options = {signWith};
107
+ if (message) return await this.peer.pub(topic, message, options);
108
+ if (!killTag) return null;
109
+ return await this.peer.kill(topic, killTag, options);
110
+ }
111
+ static regionCode(lat, lng) { // Answer containing region code.
112
+ return geoCellId(lat, lng);
113
+ }
114
+ static canonicalizeRegion(lat, lng) {
115
+ // Answer a {lat, lng} that is the center of a top-level Axona region containing the given {lat, lng}.
116
+ // E.g., a precise location gets anonymized to containing top-level cell center.
117
+ return geoCellCenter(this.regionCode(lat, lng));
118
+ }
119
+ static delay(ms, result) { // Promise result after ms milliseconds.
120
+ return new Promise(resolve => setTimeout(resolve, ms, result));
121
+ }
122
+ get synaptomeSize() { // Safely answer the number of connections.
123
+ return this.node.synaptome?.size ?? 0;
124
+ }
125
+ health() { // from peer
126
+ return this.peer.health();
127
+ }
128
+ }
129
+
130
+ describe("CivilDefense", function () {
131
+ // These two are not acceptance criteria for how long the operation should take, but are instead
132
+ // how long we are willing to wait in in the test before assuming it is never going to happen.
133
+ const connectAllowanceMS = 20e3;
134
+ const deliveryAllowanceMS = 10e3;
135
+ const location = regionCenter('uswest');
136
+
137
+ let alice, bob, carol, david, emma;
138
+ let aliceKillTag, currentOperation;
139
+
140
+ const version = Date.now(); // Each run gets a new topic.
141
+ const eventName = `civilDefense.io:${version}:pubsub-test`;
142
+ const regionCode = Node.regionCode(location.lat, location.lng);
143
+ class TestNode extends Node { // Version of Node specific to these tests.
144
+ async publish({message, killTag, ...rest}) { // Log publish, and default eventName/region for these tests.
145
+ const published = await super.publish({eventName, region:regionCode, message, killTag, ...rest});
146
+ this.log(killTag ? 'kill' : 'publish', currentOperation, message || killTag);
147
+ return published;
148
+ }
149
+ resetExpectedPromise(expectedCount) {
150
+ // Sets a current internal handler, without updating subscription, which records handled messages in this.events[currentOperation].
151
+ // Returns a ready promise that resolves when the expectedCount has been received.
152
+ return this.ready = new Promise(resolve => {
153
+ const handlerTime = Date.now();
154
+ this.handler = ({message, receiver, ts:pubTime}) => { // Ensure that the receiver's events[currentOperation] is a list, and push message on to it.
155
+ // FIXME: ts is undefined for a kill, which is weird:
156
+ // 1. I would think that Axona needs the time in order to dedupe and order properly?
157
+ // 2. The app may need the time, especially since we are not reliably getting events in ts order. (See "wrong order" comment, below.)
158
+ pubTime ||= 0;
159
+ const start = Math.max(pubTime, handlerTime);
160
+ const elapsed = Date.now() - start;
161
+ const data = receiver.events[currentOperation] ||= [];
162
+ receiver.log(`received ${currentOperation} ${data.length} ${message} after ${elapsed.toLocaleString()} ms.`);
163
+ data.push(message || null);
164
+ if (data.length < expectedCount) return;
165
+ resolve();
166
+ };
167
+ });
168
+ }
169
+ async subscribe({expectedCount = 0, handler = (...rest) => this.handler?.(...rest), ...rest} = {}) {
170
+ const ready = this.resetExpectedPromise(expectedCount);
171
+ const subscribed = await super.subscribe({eventName, region:regionCode, handler});
172
+ this.log('subscribed, expecting', expectedCount);
173
+ return subscribed;
174
+ }
175
+ }
176
+ const inOrder = ['alice pub', ' bob pub'];
177
+
178
+ beforeAll(async function () {
179
+ [alice, bob] = await Promise.all([
180
+ TestNode.create({location, events: {}, label: 'alice', authorIdentity: '{"kind":"author","pubkey":"b08988518013fabc5949353281d263cb7916f273f3f8badf0b16443a67d0b05c","privkey":"MC4CAQAwBQYDK2VwBCIEIC7ZrU8C2WSBdoBQW/JoE0ZYRkp/kFcCGf7H25DVqM17","createdAt":1782508727637}'}),
181
+ TestNode.create({location, events: {}, label: ' bob', authorIdentity: '{"kind":"author","pubkey":"6447bae1e8f1d5c99243251b43b4d354fb4928c10b69076563617c06fff46ca3","privkey":"MC4CAQAwBQYDK2VwBCIEIGGHVqedJBPtzSDR3P03HHpWTPlyXteHJAPtABmdwujM","createdAt":1782508849433}'})
182
+ ]);
183
+ await Promise.all([alice.subscribe({expectedCount: 2}), bob.subscribe({expectedCount: 2})]);
184
+ // Now carol joins and subscribes.
185
+ carol = await TestNode.create({location, events: {}, label: 'carol', authorIdentity: '{"kind":"author","pubkey":"9fac71086211be29c685fc8aeab7725f4e78a0476482b817b9160273a40812e8","privkey":"MC4CAQAwBQYDK2VwBCIEILSc0paO923M0X8d8ux6JkjEFz65gk2BVBPlTtlZD6Q3","createdAt":1782508860832}'});
186
+ await carol.subscribe({expectedCount: 2});
187
+
188
+ currentOperation = 'initial';
189
+ // 'alice pub' starts and completes before 'bob pub' starts.
190
+ aliceKillTag = await alice.publish({message: 'alice pub'});
191
+ await TestNode.delay(500); // FIXME: without this delay, subscription handlers are called in the wrong order.
192
+ await bob.publish({message: ' bob pub'});
193
+ await Promise.all([alice.ready, bob.ready, carol.ready]);
194
+ }, 2 * connectAllowanceMS + deliveryAllowanceMS);
195
+ describe("initial", function () {
196
+ it("alice receives all pubs from herself and bob", function () { expect(alice.events.initial).toEqual(inOrder); });
197
+ it(" bob receives all pubs from himself and bob", function () { expect( bob.events.initial).toEqual(inOrder); });
198
+ it("carol receives all pubs from alice and bob", function () { expect(carol.events.initial).toEqual(inOrder); });
199
+ });
200
+
201
+ describe("restart", function () {
202
+ beforeAll(async function () {
203
+ await bob.disconnect();
204
+ carol.handler = null; // We're not going to disconnect this instance of carol, but let's not get confused by subscription callbacks.
205
+ [bob, carol, david] = await Promise.all([
206
+ TestNode.create({location, events: bob.events, label: ' bob', authorIdentity: bob.authorIdentity}),
207
+ TestNode.create({location, events: carol.events, label: 'carol', authorIdentity: carol.authorIdentity}),
208
+ TestNode.create({location, events: {}, label: 'david', authorIdentity: '{"kind":"author","pubkey":"4071c632d8aedfeee7293c23a539b368211cf903722d12d521eeda226047e1ed","privkey":"MC4CAQAwBQYDK2VwBCIEIN/rAuWAlhP2blF3neaoXxLM727hQ1ZzR0hsg9yYS6hd","createdAt":1782508870567}'})
209
+ ]);
210
+ currentOperation = 'restart';
211
+ await Promise.all([bob.subscribe({expectedCount: 2}), carol.subscribe({expectedCount: 2}), david.subscribe({expectedCount: 2})]);
212
+ await Promise.all([bob.ready, carol.ready, david.ready]);
213
+ }, 1 * connectAllowanceMS + deliveryAllowanceMS);
214
+ it(" bob receives all pubs from himself and bob", function () { expect( bob.events.restart).toEqual(inOrder); });
215
+ it("carol receives all pubs from alice and bob", function () { expect(carol.events.restart).toEqual(inOrder); });
216
+ it("david receives all pubs from alice and bob", function () { expect(david.events.restart).toEqual(inOrder); });
217
+
218
+ describe("after kill", function () {
219
+ beforeAll(async function () {
220
+ currentOperation = 'kill';
221
+ alice.resetExpectedPromise(1);
222
+ bob.resetExpectedPromise(1);
223
+ carol.resetExpectedPromise(1);
224
+ david.resetExpectedPromise(1);
225
+ await alice.publish({killTag: aliceKillTag});
226
+ await TestNode.delay(500); // FIXME: without this delay, someone already subscribed (like Bob) sometimes (rarely) doesn't get the kill callback.
227
+ emma = await TestNode.create({location, events: {}, label: ' emma', authorIdentity: '{"kind":"author","pubkey":"ddd028fe0436ce4d1eaf697b3be3971ff009fabcd4949028adf8305d5291e1f7","privkey":"MC4CAQAwBQYDK2VwBCIEIO0eoNtBvNuA84mFckAIR0ozuwFbMwJhR+FbMOq+ZALf","createdAt":1782509271005}'});
228
+ emma.subscribe({expectedCount: 1});
229
+ await Promise.all([alice.ready, bob.ready, carol.ready, david.ready, emma.ready]);
230
+ }, connectAllowanceMS + deliveryAllowanceMS);
231
+ it("alice receives kill", function () { expect(alice.events.kill).toEqual([null]); });
232
+ it(" bob receives kill", function () { expect( bob.events.kill).toEqual([null]); });
233
+ it("carol receives kill", function () { expect(carol.events.kill).toEqual([null]); });
234
+ it("david receives kill", function () { expect(david.events.kill).toEqual([null]); });
235
+ it(" emma only receives unkilled pub from bob", function () { expect( emma.events.kill).toEqual(inOrder.slice(1)); });
236
+
237
+ });
238
+ });
239
+ afterAll(async function () {
240
+ await alice.disconnect();
241
+ await bob.disconnect();
242
+ await carol?.disconnect();
243
+ await david?.disconnect();
244
+ await emma?.disconnect();
245
+ });
246
+ });
@@ -0,0 +1,280 @@
1
+ /*
2
+ FIXME: Things that either don't pass, or require undocumented workarounds.
3
+ TODO: Things that ought to be dealt with at some point, but can be deferred until later.
4
+ CURRENTLY:
5
+ - This passes (with the FIXMEs in place) in main/3.8.0
6
+ - This usually fails to receive some of the expected subscription callbacks in testnet/4.3.2, and thus hangs.
7
+
8
+ To RUN, e.g., in NodeJS:
9
+ - You may need to adjust the path to webTransport. See the first TODO entry.
10
+ - To switch between them, don't forget to change the wss url a few lines down from hehre.
11
+ - Have jasmine or the like installed and initialized, and then e.g., npx jasmine spec/axonSpec.js.
12
+
13
+ It is worth running this several times. It sometimes works once, and then fails or has enormous connect times on another run.
14
+
15
+ The logging tells the story.
16
+ Alice, Bob, and Carol are Node instances. (Defined below, followed by the Jasmine test suite.)
17
+ Alice and Bob will subscribe and publish to an open/since:'all' topic.
18
+ Carol will join and subscribe between the previous subscriptions and their publications.
19
+ After publications, Bob will politely disconnect, and then restart and subscribe again to get same results.
20
+ Carol will restart without an explicit disconnect, and subscribe again after publications.
21
+ David will join and subscribe after publications.
22
+ */
23
+ const { describe, it, expect, beforeAll, afterAll, BigInt } = globalThis;
24
+ import { AxonaPeer, AxonaDomain, NeuronNode, createNodeIdentity, createAuthorIdentity, regionCenter, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION, deriveTopicId, metricTopic } from '@axona/protocol';
25
+
26
+ // TODO: What is the right way to use Axona web transport. It doesn't seem to provide either a functioning export nor declare its dependencies.
27
+ import { webTransport } from '@axona/protocol/transport/web/index.js';
28
+ globalThis.RTCPeerConnection ||= await import('node-datachannel/polyfill').then(ndc => ndc.RTCPeerConnection);
29
+
30
+ class Node { // Stuff we have to do every time. TODO: build something like this into Axona.
31
+ static version = KERNEL_VERSION;
32
+ log(...rest) {
33
+ console.log(new Date(), this.label, this.transportIdentity.id.slice(0, 10), ...rest);
34
+ }
35
+ static async create({location, transportIdentity, bridgeUrl = 'wss://testnet.axona.net',
36
+ label = 'network', store, authorIdentity,
37
+ synapseCount = 4, timeoutMs = 10e3, ...rest} = {}) {
38
+ // Promise a ready-to-use network peer.
39
+
40
+ const start = Date.now();
41
+ // Complex location/identity behavior: Must pass either identity or location {lat/lng} (either can be a promise).
42
+ if (!transportIdentity) location ||= this.canonicalizeRegion(await location);
43
+ transportIdentity ||= createNodeIdentity(location);
44
+ transportIdentity = await transportIdentity;
45
+ location ||= transportIdentity.region; // TODO: don't use the same term 'region' in different ways.
46
+
47
+ if (typeof(authorIdentity) === 'string') // TODO: this is pretty awkward.
48
+ authorIdentity = createAuthorIdentity({persistAs: label, store: {get() { return authorIdentity; }}});
49
+ authorIdentity ||= createAuthorIdentity({persistAs: label, store});
50
+ authorIdentity = await authorIdentity;
51
+
52
+ // FIXME: sometimes fails with "UpgradeRequiredError: bridge closed socket before handshake completed"
53
+ const transport = webTransport({bridgeUrl, identity: transportIdentity});
54
+ const node = new NeuronNode({lat: location.lat, lng: location.lng, id: BigInt('0x' + transportIdentity.id)});
55
+ node.transport = transport; // TODO: pass in to constructor?
56
+ const domain = new AxonaDomain({ k: 20 }); // TODO: can't this be defaulted in AxonaPeer?
57
+ const peer = new AxonaPeer({domain, node, identity: transportIdentity, transport});
58
+
59
+ const self = new this();
60
+ Object.assign(self, {transportIdentity, transport, node, peer, label, authorIdentity, ...rest});
61
+ self.log(`created with kernel version ${this.version} in ${(Date.now() - start).toLocaleString()} ms.`);
62
+ await self.connect({synapseCount, timeoutMs});
63
+ return self;
64
+ }
65
+ async connect({synapseCount = 4, timeoutMs = 10e3} = {}) {
66
+ // Returned promise resolves when ready for use.
67
+ // TODO: Currently, one cannot disconnect() and then later connect() - one is likely to get TransportError: bridge socket closed before open.
68
+ // So as it stands there's not really any point in this being a separate method from create().
69
+ const start = Date.now();
70
+ await this.transport.start(this.transportIdentity.id);
71
+ await this.peer.join();
72
+ if (parseInt(this.constructor.version) < 4) {
73
+ const t0 = Date.now();
74
+ while (Date.now() - t0 < timeoutMs) {
75
+ const size = this.synaptomeSize;
76
+ if (size >= synapseCount) break;
77
+ await this.constructor.delay(200);
78
+ }
79
+ } else {
80
+ await this.peer.ready({ minPeers: synapseCount, timeoutMs });
81
+ }
82
+ const elapsed = Date.now() - start;
83
+ // FIXME: most of the time, this completes in under two seconds. But not infrequently, it is much more.
84
+ // I've see it take 30 seconds -- even when there are nearby nodes standing by.
85
+ this.log(`connected to ${this.synaptomeSize} nodes in ${elapsed.toLocaleString()} ms${elapsed < 2e3 ? '.' : '!!!!!!!!!!!!!!!!'}`);
86
+ return this;
87
+ }
88
+ async disconnect() { // Politely close network connection.
89
+ const start = Date.now();
90
+ const health = this.health();
91
+ await this.peer.leave();
92
+ this.log(`disconnected with ${health.peers.length} connections and ${health.axonRoles.length} axons in ${(Date.now() - start).toLocaleString()} ms.`);
93
+ await this.peer.stop();
94
+ }
95
+ async subscribe({eventName, region, owner, since = 'all', handler}) { // Assign handler for eventName, or remove any handler if falsy.
96
+ const topic = {region, name: eventName};
97
+ if (owner) topic.owner = owner;
98
+ if (handler) {
99
+ const callback = async ({...rest}) => handler({receiver: this, ...rest}); // Add receiver to envelope.
100
+ await this.peer.sub(topic, callback, {since});
101
+ } else {
102
+ await this.peer.unsub(topic, {});
103
+ }
104
+ }
105
+ async subscribeOpenMetrics({eventName, region, since = 'all', handler}) { // Assign handler for metrics about eventName, or remove any handler if falsy.
106
+ const topic = {region, name: eventName};
107
+ const id = await deriveTopicId(topic);
108
+ const topicIdentifier = metricTopic(id);
109
+ if (handler) {
110
+ const callback = async ({message, ...rest}) => handler({receiver: this, message: JSON.parse(message), ...rest}); // Add receiver to envelope, and parse message string.
111
+ await this.peer.sub(topicIdentifier, callback, {since});
112
+ } else {
113
+ await this.peer.unsub(topicIdentifier, {});
114
+ }
115
+ }
116
+ async publish({eventName, region, owner, signWith = this.authorIdentity, killTag, message}) {
117
+ // Publish data to subscribers of eventName.
118
+ const topic = {region, name: eventName};
119
+ if (owner) topic.owner = owner;
120
+ const options = {signWith};
121
+ if (message) return await this.peer.pub(topic, message, options);
122
+ if (!killTag) return null;
123
+ return await this.peer.kill(topic, killTag, options);
124
+ }
125
+ static regionCode(lat, lng) { // Answer containing region code.
126
+ return geoCellId(lat, lng);
127
+ }
128
+ static canonicalizeRegion(lat, lng) {
129
+ // Answer a {lat, lng} that is the center of a top-level Axona region containing the given {lat, lng}.
130
+ // E.g., a precise location gets anonymized to containing top-level cell center.
131
+ return geoCellCenter(this.regionCode(lat, lng));
132
+ }
133
+ static delay(ms, result) { // Promise result after ms milliseconds.
134
+ return new Promise(resolve => setTimeout(resolve, ms, result));
135
+ }
136
+ get synaptomeSize() { // Safely answer the number of connections.
137
+ return this.node.synaptome?.size ?? 0;
138
+ }
139
+ health() { // from peer
140
+ return this.peer.health();
141
+ }
142
+ host() { // through peer
143
+ return this.peer.host();
144
+ }
145
+ }
146
+
147
+ describe("CivilDefense", function () {
148
+ // These two are not acceptance criteria for how long the operation should take, but are instead
149
+ // how long we are willing to wait in in the test before assuming it is never going to happen.
150
+ const connectAllowanceMS = 20e3;
151
+ const deliveryAllowanceMS = 60e3;
152
+
153
+ //const location = regionCenter('uscentlw');
154
+ const location = regionCenter('uswest'); // TODO: pick one
155
+
156
+ let alice, bob, carol, david, emma;
157
+ let aliceKillTag, currentOperation;
158
+
159
+ const version = Date.now(); // Each run gets a new topic.
160
+ const eventName = `civilDefense.io:${version}:pubsub-test`;
161
+ const regionCode = Node.regionCode(location.lat, location.lng);
162
+ class TestNode extends Node { // Version of Node specific to these tests.
163
+ async publish({message, killTag, ...rest}) { // Log publish, and default eventName/region for these tests.
164
+ const published = await super.publish({eventName, region:regionCode, message, killTag, ...rest});
165
+ this.log(killTag ? 'kill' : 'publish', currentOperation, message || killTag);
166
+ return published;
167
+ }
168
+ resetExpectedPromise(expectedCount) {
169
+ // Sets a current internal handler, without updating subscription, which records handled messages in this.events[currentOperation].
170
+ // Returns a ready promise that resolves when the expectedCount has been received.
171
+ return this.ready = new Promise(resolve => {
172
+ const handlerTime = Date.now();
173
+ this.handler = ({message, receiver, ts:pubTime}) => { // Ensure that the receiver's events[currentOperation] is a list, and push message on to it.
174
+ // FIXME: ts is undefined for a kill, which is weird:
175
+ // 1. I would think that Axona needs the time in order to dedupe and order properly?
176
+ // 2. The app may need the time, especially since we are not reliably getting events in ts order. (See "wrong order" comment, below.)
177
+ pubTime ||= 0;
178
+ const start = Math.max(pubTime, handlerTime);
179
+ const elapsed = Date.now() - start;
180
+ const data = receiver.events[currentOperation] ||= [];
181
+ receiver.log(`received ${currentOperation} ${data.length} ${message} after ${elapsed.toLocaleString()} ms.`);
182
+ data.push(message || null);
183
+ if (data.length < expectedCount) return;
184
+ resolve();
185
+ };
186
+ });
187
+ }
188
+ async subscribe({expectedCount = 0, handler = (...rest) => this.handler?.(...rest), ...rest} = {}) {
189
+ const ready = this.resetExpectedPromise(expectedCount);
190
+ const subscribed = await super.subscribe({eventName, region:regionCode, handler});
191
+ this.log('subscribed, expecting', expectedCount);
192
+ return subscribed;
193
+ }
194
+ // subscribeOpenMetrics({eventName, region, since = 'all', handler}) { // resolves with first metrics to come through.
195
+ // //return new Promise(resolve => {
196
+ // //const callback = async ({message}) => {console.log('got metrics', message); resolve(message);}; // Add receiver to envelope.
197
+ // const callback = handler;
198
+ // super.subscribeOpenMetrics({eventName, region, since, callback});
199
+ // //});
200
+ // }
201
+ }
202
+ const inOrder = ['alice pub', ' bob pub'];
203
+
204
+ beforeAll(async function () {
205
+ [alice, bob] = await Promise.all([
206
+ TestNode.create({location, events: {}, label: 'alice', authorIdentity: '{"kind":"author","pubkey":"b08988518013fabc5949353281d263cb7916f273f3f8badf0b16443a67d0b05c","privkey":"MC4CAQAwBQYDK2VwBCIEIC7ZrU8C2WSBdoBQW/JoE0ZYRkp/kFcCGf7H25DVqM17","createdAt":1782508727637}'}),
207
+ TestNode.create({location, events: {}, label: ' bob', authorIdentity: '{"kind":"author","pubkey":"6447bae1e8f1d5c99243251b43b4d354fb4928c10b69076563617c06fff46ca3","privkey":"MC4CAQAwBQYDK2VwBCIEIGGHVqedJBPtzSDR3P03HHpWTPlyXteHJAPtABmdwujM","createdAt":1782508849433}'})
208
+ ]);
209
+ await Promise.all([alice.subscribe({expectedCount: 2}), bob.subscribe({expectedCount: 2})]);
210
+ // Now carol joins and subscribes.
211
+ carol = await TestNode.create({location, events: {}, label: 'carol', authorIdentity: '{"kind":"author","pubkey":"9fac71086211be29c685fc8aeab7725f4e78a0476482b817b9160273a40812e8","privkey":"MC4CAQAwBQYDK2VwBCIEILSc0paO923M0X8d8ux6JkjEFz65gk2BVBPlTtlZD6Q3","createdAt":1782508860832}'});
212
+ await carol.subscribe({expectedCount: 2});
213
+
214
+ currentOperation = 'initial';
215
+ // 'alice pub' starts and completes before 'bob pub' starts.
216
+ aliceKillTag = await alice.publish({message: 'alice pub'});
217
+ await TestNode.delay(500); // FIXME: without this delay, subscription handlers are called in the wrong order.
218
+ console.log('alice published');
219
+ await bob.publish({message: ' bob pub'});
220
+ console.log('bob published');
221
+ //alice.subscribeOpenMetrics({eventName, region:regionCode, handler: envelope => console.log('*** fixme got metrics', envelope)});
222
+ await Promise.all([alice.ready, bob.ready, carol.ready]);
223
+ //await TestNode.delay(4e3);
224
+ //expect(await statsPromise).toBe(2);
225
+ console.log('finished', currentOperation);
226
+ }, 2 * connectAllowanceMS + deliveryAllowanceMS);
227
+ describe("initial", function () {
228
+ it("alice receives all pubs from herself and bob", function () { expect(alice.events.initial).toEqual(inOrder); });
229
+ it(" bob receives all pubs from himself and bob", function () { expect( bob.events.initial).toEqual(inOrder); });
230
+ it("carol receives all pubs from alice and bob", function () { expect(carol.events.initial).toEqual(inOrder); });
231
+ });
232
+
233
+ describe("restart", function () {
234
+ beforeAll(async function () {
235
+ await bob.disconnect();
236
+ carol.handler = null; // We're not going to disconnect this instance of carol, but let's not get confused by subscription callbacks.
237
+ [bob, carol, david] = await Promise.all([
238
+ TestNode.create({location, events: bob.events, label: ' bob', authorIdentity: bob.authorIdentity}),
239
+ TestNode.create({location, events: carol.events, label: 'carol', authorIdentity: carol.authorIdentity}),
240
+ TestNode.create({location, events: {}, label: 'david', authorIdentity: '{"kind":"author","pubkey":"4071c632d8aedfeee7293c23a539b368211cf903722d12d521eeda226047e1ed","privkey":"MC4CAQAwBQYDK2VwBCIEIN/rAuWAlhP2blF3neaoXxLM727hQ1ZzR0hsg9yYS6hd","createdAt":1782508870567}'})
241
+ ]);
242
+ currentOperation = 'restart';
243
+ await Promise.all([bob.subscribe({expectedCount: 2}), carol.subscribe({expectedCount: 2}), david.subscribe({expectedCount: 2})]);
244
+ await Promise.all([bob.ready, carol.ready, david.ready]);
245
+ console.log('finished', currentOperation);
246
+ }, 1 * connectAllowanceMS + deliveryAllowanceMS);
247
+ it(" bob receives all pubs from himself and bob", function () { expect( bob.events.restart).toEqual(inOrder); });
248
+ it("carol receives all pubs from alice and bob", function () { expect(carol.events.restart).toEqual(inOrder); });
249
+ it("david receives all pubs from alice and bob", function () { expect(david.events.restart).toEqual(inOrder); });
250
+
251
+ describe("after kill", function () {
252
+ beforeAll(async function () {
253
+ currentOperation = 'kill';
254
+ alice.resetExpectedPromise(1);
255
+ bob.resetExpectedPromise(1);
256
+ carol.resetExpectedPromise(1);
257
+ david.resetExpectedPromise(1);
258
+ await alice.publish({killTag: aliceKillTag});
259
+ await TestNode.delay(500); // FIXME: without this delay, someone already subscribed (like Bob) sometimes (rarely) doesn't get the kill callback.
260
+ emma = await TestNode.create({location, events: {}, label: ' emma', authorIdentity: '{"kind":"author","pubkey":"ddd028fe0436ce4d1eaf697b3be3971ff009fabcd4949028adf8305d5291e1f7","privkey":"MC4CAQAwBQYDK2VwBCIEIO0eoNtBvNuA84mFckAIR0ozuwFbMwJhR+FbMOq+ZALf","createdAt":1782509271005}'});
261
+
262
+ await emma.subscribe({expectedCount: 1});
263
+ await Promise.all([alice.ready, bob.ready, carol.ready, david.ready, emma.ready]);
264
+ console.log('finished', currentOperation);
265
+ }, connectAllowanceMS + deliveryAllowanceMS);
266
+ it("alice receives kill", function () { expect(alice.events.kill).toEqual([null]); });
267
+ it(" bob receives kill", function () { expect( bob.events.kill).toEqual([null]); });
268
+ it("carol receives kill", function () { expect(carol.events.kill).toEqual([null]); });
269
+ it("david receives kill", function () { expect(david.events.kill).toEqual([null]); });
270
+ it(" emma only receives unkilled pub from bob", function () { expect( emma.events.kill).toEqual(inOrder.slice(1)); });
271
+ });
272
+ });
273
+ afterAll(async function () {
274
+ await alice.disconnect();
275
+ await bob.disconnect();
276
+ await carol?.disconnect();
277
+ await david?.disconnect();
278
+ await emma?.disconnect();
279
+ });
280
+ });