@yz-social/civildefense.io 4.4.3 → 4.5.4

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.
@@ -1,7 +1,7 @@
1
1
  import { v4 as uuidv4 } from 'uuid';
2
- import { AxonaPeer, AxonaDomain, NeuronNode, createNodeIdentity, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } from '@axona/protocol';
2
+ import { createNodeIdentity, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } from '@axona/protocol';
3
3
  import { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } from '@axona/protocol/std';
4
- import { webTransport } from '@axona/protocol/transport/web/index.js';
4
+ import { connect } from '@axona/protocol/connect.js';
5
5
  globalThis.RTCPeerConnection ||= await import('node-datachannel/polyfill').then(ndc => ndc.RTCPeerConnection);
6
6
  const { BigInt, URL, File, pica } = globalThis;
7
7
 
@@ -20,60 +20,38 @@ export class P2PWebNetwork {
20
20
  static setSessionRegion = resolveSessionRegion;
21
21
  static sessionRegion = sessionRegionPromise;
22
22
  static async create({infoLogger = console.log, debugLogger,
23
- region, identity, bridgeUrl = 'wss://bridge.axona.net',
24
- synapseCount = 4, timeoutMs = 10e3} = {}) {
23
+ region = this.sessionRegion,
24
+ bridgeUrl = globalThis.process?.env.BRIDGE_URL || 'wss://bridge.axona.net',
25
+ } = {}) {
25
26
  // Promise a ready-to-use network peer.
26
- // Complex region/identity behavior: Must pass either identity or region (either can be a promise), or will wait for setSessionRegion() to be called.
27
- if (!identity) region ||= this.canonicalizeRegion(await (region || this.sessionRegion));
28
- identity ||= createNodeIdentity(region);
29
- identity = await identity;
30
- region ||= identity.region;
31
-
32
- const transport = webTransport({bridgeUrl, identity});
33
- const node = new NeuronNode({lat: region.lat, lng: region.lng, id: BigInt('0x' + identity.id)});
34
- node.transport = transport; // FIXME: pass in to constructor?
35
- const domain = new AxonaDomain({ k: 20 }); // FIXME: can't this be defaulted in AxonaPeer?
36
- const peer = new AxonaPeer({domain, node, identity, transport});
27
+ region = await region;
28
+ const { peer, nodeIdentity, transport, status, disconnect } = await connect({
29
+ bridge: bridgeUrl,
30
+ location: region,
31
+ author: false
32
+ });
37
33
 
38
34
  const network = new this();
39
- Object.assign(network, {infoLogger, debugLogger, identity, transport, node, peer});
35
+ Object.assign(network, {infoLogger, debugLogger, disconnector: disconnect, transport, nodeIdentity, peer});
40
36
  network.resetStatePromises();
41
37
  network.info(`Created network node for kernel ${this.kernelVersion} region 0x${this.regionCode(region.lat, region.lng).toString(16)}.`);
42
- await network.connect({synapseCount, timeoutMs});
38
+ const { peers, ms } = status;
39
+ network.info(`Connected ${peers} connections through ${bridgeUrl} in ${ms.toLocaleString()} ms.`);
40
+ network.attached(network);
43
41
  return network;
44
42
  }
45
43
 
46
- async connect({synapseCount = 4, timeoutMs = 10e3} = {}) {
47
- // Returned promise resolves when ready for use. Can be cycled through disconnect()/connect().
48
- await this.transport.start(this.identity.id);
49
- await this.join();
50
- this.debug('Joined', this.health().synaptomeSize, 'connections.');
51
- if (parseInt(this.constructor.kernelVersion) < 4) {
52
- const t0 = Date.now();
53
- while (Date.now() - t0 < timeoutMs) {
54
- const size = this.synaptomeSize;
55
- if (size >= synapseCount) break;
56
- await this.constructor.delay(200);
57
- }
58
- } else {
59
- await this.peer.ready({ minPeers: synapseCount, timeoutMs });
60
- }
61
- this.info('Connected', this.health().synaptomeSize, 'connections.');
62
- this.attached(this);
63
- return this;
64
- }
65
44
  async disconnect() { // Politely close network connection.
66
- const health = this.health();
67
- await this.leave();
45
+ const health = this.peer.health();
46
+ await this.disconnector();
68
47
  this.info(`disconnected with ${health.peers.length} connections and ${health.axonRoles.length} axons.`);
69
- await this.stop();
70
48
  this.resetStatePromises();
71
49
  }
72
50
  async replicateStorage() { // Let the network know that we might go away without further notice.
73
51
  // FIXME. It would be great if we could remove ourselves from any non-leaf positions in the Axon, but stay subscribed.
74
52
  }
75
53
  fastDisconnect() { // Synchronous attempt to be polite to those connected.
76
- this.leave(); // Execution is asynchronous. Will not finish -- or perhaps even really start -- within the call.
54
+ this.peer.leave(); // Execution is asynchronous. Will not finish -- or perhaps even really start -- within the call.
77
55
  }
78
56
 
79
57
  async chunkifyString({string, region, signWith = this.constructor.currentPublishIdentity, owner = signWith.authorId}) {
@@ -88,7 +66,7 @@ export class P2PWebNetwork {
88
66
  return bytesToString(data.bytes);
89
67
  }
90
68
 
91
- static getCanvas(file) { // Promise a Canvas from a File of type image/*.
69
+ static getCanvas(file) { // Promise a Canvas from a File of type image/*. ONLY IN BROWSERS!
92
70
  return new Promise((resolve, reject) => {
93
71
  const img = new Image();
94
72
  const canvas = document.createElement('canvas');
@@ -104,7 +82,7 @@ export class P2PWebNetwork {
104
82
  img.src = URL.createObjectURL(file);
105
83
  });
106
84
  }
107
- static async downsampledBlob({blob, outputType = 'image/jpeg', maxDimension = 1024}) {
85
+ static async downsampledBlob({blob, outputType = 'image/jpeg', maxDimension = 1024}) { // ONLY IN BROWSERS!
108
86
  // Promise a reasonably sized Blob (or File) for a given Blob of type image/*, else blob unchanged.
109
87
  if (!blob.type.startsWith('image/')) return blob;
110
88
 
@@ -152,7 +130,7 @@ export class P2PWebNetwork {
152
130
  return new File([blob], filename, {type: blob.type});
153
131
  }
154
132
  async chunkifyBlob({blob, region, signWith = this.constructor.currentPublishIdentity, owner = signWith.authorId, maxDimension = 1024, ...rest}) {
155
- // Publish Blob (or File) and answer an identifier that can be used to re-assemble.
133
+ // Publish Blob (or File) and answer an identifier that can be used to re-assemble. Truthy maxDimension works only in browsers!
156
134
  if (!blob.size) throw new Error(`Cannot chunkify empty Blob.`);
157
135
  if (maxDimension) blob = await this.constructor.downsampledBlob({blob, maxDimension});
158
136
  const {type:mime, name} = blob;
@@ -160,13 +138,16 @@ export class P2PWebNetwork {
160
138
  const buffer = await blob.arrayBuffer();
161
139
  const u8 = new Uint8Array(buffer);
162
140
  //console.log('blob', blob.size, u8.length);
163
- return await publishChunkedBytes(this.peer, u8, {topic, signWith, mime, name, ...rest});
141
+ const data = await publishChunkedBytes(this.peer, u8, {topic, signWith, mime, name, ...rest});
142
+ this.debug('chunked', data);
143
+ return data;
164
144
  }
165
145
  async assembleChunkedDataURL(topic) { // Promise {bytes, mime, name, dataURL} that was chunkified to topic.
166
146
  const data = await receiveChunkedBytes(this.peer, topic, {/*, onProgress: console.log*/});
167
147
  // Using dataURL is not terribly efficient, but it is convenient, because formatReplies can return HTML strings with all the data in them,
168
148
  // instead of, e.g., needing javascript to later set properties of elements to createObjectURL of a Blob.
169
149
  data.dataURL = this.constructor.u82dataURL(data.bytes, data.mime);
150
+ this.debug('assembled', topic);
170
151
  return data;
171
152
  }
172
153
 
@@ -180,7 +161,7 @@ export class P2PWebNetwork {
180
161
  if (handler) {
181
162
  const callback = async envelope => {
182
163
  const {message, deleted, msgId, signerPubkey, topic, ts} = envelope;
183
- //console.log('fired', {msgId, topic, ts, signerPubkey, deleted, message});
164
+ this.debug('received', {msgId, topic, ts, signerPubkey, deleted, message});
184
165
  if (deleted) {
185
166
  handler({subject: msgId, payload: null, agent: signerPubkey, topic, ts}); // fixme remove topic, ts here and below.
186
167
  return;
@@ -199,13 +180,19 @@ export class P2PWebNetwork {
199
180
  const topic = {region, name: eventName};
200
181
  if (owner) topic.owner = owner;
201
182
  const options = {signWith};
202
- //console.log({topic, subject, payload, issuedTime, rest, signWith});
183
+ this.debug('published', {topic, subject, payload, issuedTime, rest, signWith});
203
184
  if (payload) return await this.peer.pub(topic, {issuedTime, payload, ...rest}, options);
204
185
  // The next would not normally happen, but until since:'latest' works, we need a way to send a null payload and have the handler delete the entry.
205
186
  if (!subject) return await this.peer.pub(topic, {issuedTime, payload, ...rest}, options);
206
187
  return await this.peer.kill(topic, subject, options);
207
188
  }
208
189
 
190
+ host() {
191
+ return this.peer.host();
192
+ }
193
+ unhost() {
194
+ return this.peer.unhost();
195
+ }
209
196
  // Mostly internal stuff.
210
197
  static regionCode(lat, lng) { // Answer containing region code.
211
198
  return geoCellId(lat, lng);
@@ -228,20 +215,12 @@ export class P2PWebNetwork {
228
215
  // E.g., a precise location gets anonymized to containing top-level cell center.
229
216
  return this.regionCenter(this.regionCode(lat, lng));
230
217
  }
231
- get synaptomeSize() { // Safely answer the number of connections.
232
- return this.node.synaptome?.size ?? 0;
233
- }
234
- // TODO: Integrate with AxonaPeer's complex logging.
218
+ // Todo: Integrate with AxonaPeer's complex logging.
235
219
  debug(...rest) { // Add debug logspam.
236
- this.debugLogger?.(this.identity.id, ...rest);
220
+ this.debugLogger?.(this.nodeIdentity.id, ...rest);
237
221
  }
238
222
  info(...rest) { // Add debug logspam.
239
- (this.infoLogger || this.debugLogger)?.(this.identity.id, ...rest);
223
+ (this.infoLogger || this.debugLogger)?.(this.nodeIdentity.id, ...rest);
240
224
  }
241
225
  }
242
226
  export default P2PWebNetwork;
243
-
244
- // For now, we want to override publish and subscribe, but that conflicts with internal messages on AxonaPeer.
245
- // Thus P2PWebNetwork has an AxonaPeer, instead of inheriting from it. And thus we need forwarding messages.
246
- ['join', 'leave', 'stop', 'health', 'host', 'unhost']
247
- .forEach(methodName => P2PWebNetwork.prototype[methodName] = function (...rest) {return this.peer[methodName](...rest);});
@@ -2,7 +2,7 @@ const { Request, Response, URL, localStorage, BroadcastChannel } = globalThis;
2
2
  import { appVersion } from './versions.js';
3
3
  import { resetInactivityTimer, clickTip } from './main.js';
4
4
  import { openDisplay } from './display.js';
5
- import { go } from './map.js';
5
+ import { go } from './alert.js';
6
6
  import { Int } from './translations.js';
7
7
 
8
8
  /*
@@ -71,7 +71,10 @@ function newVersionAvailable(newVersion) {
71
71
  updateText.textContent = `${Int`Version`} ${newVersion} ${Int`available`}.`;
72
72
  openDisplay('updateContainer');
73
73
  }
74
- async function installUpdate(newVersion) {
74
+ async function installUpdate(event, newVersion) {
75
+ event.stopPropagation();
76
+ event.target.textContent = "Installing..."; // In case there is some delay, tell the user what we're trying to do. Will be cleared with reload.
77
+ event.target.disabled = true;
75
78
  await caches.delete(appVersion); // Must be before cacheSource, or we'll just recache the same files!
76
79
  await cacheSource(newVersion);
77
80
  // Reload, but convince all browsers to re-"fetch" (through the new service worker that is now running).
@@ -91,11 +94,9 @@ await navigator.serviceWorker
91
94
  let serviceVersion;
92
95
  // No need to reset button/status on click, because we will be reloading.
93
96
  const installText = Int`Update to a new version of this app.`;
94
- clickTip(downloadButton, installText, () => installUpdate(serviceVersion));
95
- clickTip(downloadButton2, installText, () => event => {
96
- event.stopPropagation();
97
- installUpdate(serviceVersion);
98
- });
97
+ const installHandler = event => installUpdate(event, serviceVersion);
98
+ clickTip(downloadButton, installText, installHandler);
99
+ clickTip(downloadButton2, installText, installHandler);
99
100
  clickTip(checkButton, Int`Check to see if a new version of the app is available.`, async event => {
100
101
  resetInactivityTimer();
101
102
  event.stopPropagation();
@@ -71,7 +71,7 @@ const translations = {
71
71
  ['#newVersionHeader']: {en: "New version available", es: "Nueva versión disponible"},
72
72
  ['#updateNowQuestion']: {en: "Would you like to update now?", es: "¿Le gustaría actualizar ahora?"},
73
73
  ['#updateReload']: {en: "All CivilDefense.io tabs will reload.", es: "Todas las pestañas de CivilDefense.io se recargarán."},
74
- ['#updateDefer']: {en: "Alternatively, you can update later through the button in About.", es: "Alternativamente, puede actualizar más tarde a través del botón en «Acerca de»."},
74
+ ['#updateDefer']: {en: 'Alternatively, you can update later through the "CD" button again.', es: 'Alternativamente, puede actualizar más tarde utilizando nuevamente el botón "CD".'},
75
75
  ['#downloadUpdates2']: {en: "yes, update", es: "Sí, actualizar."},
76
76
  ['#downloadDefer']: {en: "no, not yet", es: "No, todavía no."},
77
77
  ['No update at']: {es: "No hay actualizaciones a las"},
@@ -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.4.3';
3
+ const serviceVersion = '4.5.4';
4
4
 
5
5
  async function cacheFirst({request, event}) {
6
6
  // Handle request from any cache, else fetch and store it in serviceCache.
@@ -82,7 +82,9 @@ async function cacheSource(version, event) { // Cache source in the given versio
82
82
  "javascripts/versions.js",
83
83
  "javascripts/main.js",
84
84
  "javascripts/display.js",
85
- "javascripts/map.js",
85
+ "javascripts/conversation.js",
86
+ "javascripts/alert.js",
87
+ "javascripts/map.js",
86
88
  "javascripts/hashtags.js",
87
89
  "javascripts/s2.js",
88
90
  "javascripts/agent.js",
@@ -163,6 +165,7 @@ async function cacheSource(version, event) { // Cache source in the given versio
163
165
 
164
166
  // TODO: the libraries
165
167
  ].map(name => new Request(name, {cache: 'no-store'}))); // Might not be necessary, but if any browsers insist on their own caching...
168
+
166
169
  await Promise.all([
167
170
  // These are referenced within material web, but missing. Turns out we don't need them,
168
171
  // but let's cache empty responses to keep the console cleaner.
package/server/app.js CHANGED
@@ -18,7 +18,7 @@ const argv = yargs(hideBin(process.argv))
18
18
  .option('nPortals', {
19
19
  alias: 'p',
20
20
  type: 'number',
21
- default: logicalCores,
21
+ default: Math.min(logicalCores, 5),
22
22
  description: "The number of steady nodes that handle initial connections."
23
23
  })
24
24
  .option('baseURL', {
@@ -82,7 +82,6 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
82
82
  // expressWs(app);
83
83
  // const Yz = await import('./routes/index.js');
84
84
 
85
- //console.log(`${cpus()[0].model}, ${logicalCores} logical cores. Starting ${argv.nPortals}.`);
86
85
  app.use(express.json());
87
86
 
88
87
  app.use('/images', express.static(resolve('../public/images'), {
@@ -96,18 +95,19 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
96
95
 
97
96
  app.listen(port);
98
97
  console.log('Listening on', port, 'and starting', argv.nPortals, 'nodes.');
99
- for (let i = 0; i < argv.nPortals; i++) cluster.fork();
98
+ for (let i = 0; i < argv.nPortals; i++) {
99
+ cluster.fork();
100
+ await new Promise(resolve => setTimeout(resolve, 1e3));
101
+ }
100
102
  } else {
101
103
  process.title = 'axona-starting';
102
- const { P2PWebNetwork } = await import('../public/javascripts/p2pWebNetwork.js');
103
- await P2PWebNetwork.delay(cluster.worker?.id * 1e3); // One second between startups.
104
- const { location:region } = await import('./getLocation.js'); // First invocation caches.
105
- const network = await P2PWebNetwork.create({region});
106
- process.title = 'axona-' + network.identity.id;
107
- let update = null//setInterval(() => network.info(network.health().axonRoles.map(role => role.topic)), 5e3);
104
+ const { P2PWebNetwork, location } = await import('../index.js');
105
+ const network = await P2PWebNetwork.create({region: location});
106
+ process.title = 'axona-' + network.nodeIdentity.id;
107
+ //let update = setInterval(() => network.info(network.peer.health().axonRoles.length, 'axons'), 10e3);
108
108
  process.on('SIGINT', async () => { // Leave the network politely.
109
109
  console.log(process.title, 'Shutdown for Ctrl+C');
110
- clearInterval(update)
110
+ //clearInterval(update)
111
111
  await network.disconnect();
112
112
  process.exit(0);
113
113
  });
@@ -14,6 +14,6 @@ export const data = await import(filename, {with: { type: 'json' }})
14
14
  await fs.writeFile(resolve(filename), string, 'utf8');
15
15
  return {default: JSON.parse(string)};
16
16
  });
17
- export const [lat, lng] = data.default.loc.split(',').map(parseFloat);
17
+ export const [lat, lng] = (globalThis.process?.env.LAT_LNG || data.default.loc).split(',').map(parseFloat);
18
18
  export const location = {lat, lng};
19
19
 
@@ -2,6 +2,6 @@
2
2
  "uswest/80": "37.4852,-122.2364",
3
3
  "useast/89": "40,-75",
4
4
  "uscentle/88": "35,-83",
5
- "easteu/47": "50,17",
6
- "loc": "37.4852,-122.2364"
5
+ "easteu/47": "50,17",
6
+ "loc": "37,-122"
7
7
  }
package/spec/axonSpec.js CHANGED
@@ -1,96 +1,61 @@
1
1
  /*
2
2
  FIXME: Things that either don't pass, or require undocumented workarounds.
3
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
4
 
8
5
  To RUN, e.g., in NodeJS:
9
6
  - 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.
7
+ - To switch between them, don't forget to change the wss url a few lines down from here.
11
8
  - Have jasmine or the like installed and initialized, and then e.g., npx jasmine spec/axonSpec.js.
12
9
 
13
10
  It is worth running this several times. It sometimes works once, and then fails or has enormous connect times on another run.
14
11
 
15
12
  The logging tells the story.
16
- Alice, Bob, and Carol are Node instances. (Defined below, followed by the Jasmine test suite.)
13
+ Alice, Bob, Carol, David, and Emma are Node instances. (Defined below, followed by the Jasmine test suite.)
17
14
  Alice and Bob will subscribe and publish to an open/since:'all' topic.
18
15
  Carol will join and subscribe between the previous subscriptions and their publications.
19
16
  After publications, Bob will politely disconnect, and then restart and subscribe again to get same results.
20
17
  Carol will restart without an explicit disconnect, and subscribe again after publications.
21
18
  David will join and subscribe after publications.
19
+ Alice kills her publication and Emma joins.
22
20
  */
23
21
  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';
22
+ import { createAuthorIdentity, regionCenter, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION, deriveTopicId, metricTopic } from '@axona/protocol';
23
+ import { connect } from '@axona/protocol/connect.js';
28
24
  globalThis.RTCPeerConnection ||= await import('node-datachannel/polyfill').then(ndc => ndc.RTCPeerConnection);
29
25
 
30
- class Node { // Stuff we have to do every time. TODO: build something like this into Axona.
26
+ class Node { // Stuff we have to do every time.
31
27
  static version = KERNEL_VERSION;
32
28
  log(...rest) {
33
- console.log(new Date(), this.label, this.transportIdentity.id.slice(0, 10), ...rest);
29
+ console.log(new Date(), this.label, this.nodeIdentity.id.slice(0, 10), ...rest);
34
30
  }
35
- static async create({location, transportIdentity, bridgeUrl = 'wss://testnet.axona.net',
36
- label = 'network', store, authorIdentity,
37
- synapseCount = 4, timeoutMs = 10e3, ...rest} = {}) {
31
+ static async create({location, bridgeUrl = 'wss://testnet.axona.net',
32
+ label = 'network', authorIdentity, store,
33
+ ...rest} = {}) {
38
34
  // 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
35
  if (typeof(authorIdentity) === 'string') // TODO: this is pretty awkward.
48
36
  authorIdentity = createAuthorIdentity({persistAs: label, store: {get() { return authorIdentity; }}});
49
37
  authorIdentity ||= createAuthorIdentity({persistAs: label, store});
50
38
  authorIdentity = await authorIdentity;
51
39
 
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});
40
+ const { peer, nodeIdentity, status, disconnect } = await connect({
41
+ bridge: bridgeUrl,
42
+ location: await location,
43
+ author: authorIdentity
44
+ });
58
45
 
59
46
  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});
47
+ Object.assign(self, {peer, label, authorIdentity, nodeIdentity, disconnector: disconnect, ...rest});
48
+ self.log(`created with kernel version ${this.version}.`);
49
+ const { peers, ms } = status;
50
+ self.log(`connected ${peers} connections in ${ms.toLocaleString()} ms.`);
51
+
63
52
  return self;
64
53
  }
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
54
  async disconnect() { // Politely close network connection.
89
55
  const start = Date.now();
90
- const health = this.health();
91
- await this.peer.leave();
56
+ const health = this.peer.health();
57
+ await this.disconnector();
92
58
  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
59
  }
95
60
  async subscribe({eventName, region, owner, since = 'all', handler}) { // Assign handler for eventName, or remove any handler if falsy.
96
61
  const topic = {region, name: eventName};
@@ -133,12 +98,6 @@ class Node { // Stuff we have to do every time. TODO: build something like this
133
98
  static delay(ms, result) { // Promise result after ms milliseconds.
134
99
  return new Promise(resolve => setTimeout(resolve, ms, result));
135
100
  }
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
101
  host() { // through peer
143
102
  return this.peer.host();
144
103
  }
@@ -171,10 +130,6 @@ describe("CivilDefense", function () {
171
130
  return this.ready = new Promise(resolve => {
172
131
  const handlerTime = Date.now();
173
132
  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
133
  const start = Math.max(pubTime, handlerTime);
179
134
  const elapsed = Date.now() - start;
180
135
  const data = receiver.events[currentOperation] ||= [];
@@ -214,8 +169,8 @@ describe("CivilDefense", function () {
214
169
  currentOperation = 'initial';
215
170
  // 'alice pub' starts and completes before 'bob pub' starts.
216
171
  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
172
  console.log('alice published');
173
+ await TestNode.delay(500); // FIXME: without this delay, subscription handlers are called in the wrong order.
219
174
  await bob.publish({message: ' bob pub'});
220
175
  console.log('bob published');
221
176
  //alice.subscribeOpenMetrics({eventName, region:regionCode, handler: envelope => console.log('*** fixme got metrics', envelope)});
@@ -0,0 +1,116 @@
1
+ const { describe, it, expect, beforeAll, afterAll, BigInt } = globalThis;
2
+ import { Conversation } from '../public/javascripts/conversation.js';
3
+
4
+ describe("Conversation", function () {
5
+ let agent;
6
+ let conversation;
7
+ const tag = '123';
8
+ const payload = 'cake';
9
+ beforeAll(function () {
10
+ agent = {handle: 'alice'};
11
+ conversation = Conversation.ensure({tag, payload, agent});
12
+ });
13
+ describe("creation", function () {
14
+ it("initializes properties.", function () {
15
+ expect(conversation.agent).toBe(agent);
16
+ expect(conversation.payload).toBe(payload);
17
+ });
18
+ it("remembers conversations with the same tag.", function () {
19
+ expect(Conversation.ensure({tag, payload, agent})).toBe(conversation);
20
+ });
21
+ it("Properties can be ommitted for existing tag.", function () {
22
+ expect(Conversation.ensure({tag, payload})).toBe(conversation);
23
+ });
24
+ it("rejects changes by default.", function () {
25
+ expect(() => Conversation.ensure({tag, payload: 'other', agent})).toThrow();
26
+ });
27
+ describe("caching", function () {
28
+ let keep = true;
29
+ let tag = "caching";
30
+ class CacheConversation extends Conversation {
31
+ update() { return keep && this; }
32
+ initialize() { return keep && this; }
33
+ }
34
+ it("ends with explicit removal.", function () {
35
+ let initial = Conversation.ensure({tag, agent, payload});
36
+ expect(initial).toBeTruthy();
37
+ expect(Conversation.getItem(tag)).toBe(initial);
38
+ expect(Conversation.removeItem(tag)).toBe(initial);
39
+ expect(Conversation.getItem(tag)).toBeFalsy();
40
+ });
41
+ describe("deleting data", function () {
42
+ it("keeps new if not deleting.", function () {
43
+ tag = 'keep';
44
+ let initial = Conversation.ensure({tag, agent, payload});
45
+ expect(initial).toBeTruthy();
46
+ expect(Conversation.ensure({tag, agent, payload})).toBe(initial);
47
+ expect(Conversation.getItem(tag)).toBe(initial);
48
+ });
49
+ it("is skipped if delete data.", function () {
50
+ tag = 'skip';
51
+ let initial = Conversation.ensure({tag, agent});
52
+ expect(initial).toBeFalsy();
53
+ expect(Conversation.getItem(tag)).toBeFalsy();
54
+ });
55
+ it("is removed if delete data.", function () {
56
+ tag = 'skip';
57
+ let initial = Conversation.ensure({tag, agent, payload});
58
+ expect(initial).toBeTruthy();
59
+ expect(Conversation.getItem(tag)).toBeTruthy();
60
+ Conversation.ensure({tag, agent});
61
+ expect(Conversation.getItem(tag)).toBeFalsy();
62
+ });
63
+ });
64
+ describe("null/item caching convention", function () {
65
+ // Do we really need/want this convetion?
66
+ it("keeps if initialize answers conversation.", function () {
67
+ keep = true;
68
+ tag = 'keepA';
69
+ let initial = CacheConversation.ensure({tag, agent, payload});
70
+ expect(initial).toBeTruthy();
71
+ expect(CacheConversation.ensure({tag, agent, payload})).toBe(initial);
72
+ expect(CacheConversation.getItem(tag)).toBe(initial);
73
+ });
74
+ it("is skipped if initialize answers falsy.", function () {
75
+ keep = false;
76
+ tag = 'skipA';
77
+ let initial = CacheConversation.ensure({tag, agent, payload});
78
+ expect(initial).toBeFalsy();
79
+ expect(CacheConversation.getItem(tag)).toBeFalsy();
80
+ });
81
+ it("keeps existing if update answers conversation.", function () {
82
+ keep = true;
83
+ tag = 'keepB';
84
+ let initial = CacheConversation.ensure({tag, agent, payload});
85
+ expect(CacheConversation.ensure({tag, agent, payload})).toBe(initial);
86
+ expect(CacheConversation.ensure({tag, agent, payload})).toBe(initial);
87
+ expect(CacheConversation.getItem(tag)).toBe(initial);
88
+ });
89
+ it("destroys existing if update answers falsy.", function () {
90
+ keep = true;
91
+ tag = 'skipB';
92
+ let initial = CacheConversation.ensure({tag, agent, payload});
93
+ expect(CacheConversation.ensure({tag, agent, payload})).toBe(initial);
94
+ keep = false;
95
+ expect(CacheConversation.ensure({tag, agent, payload})).toBeFalsy();
96
+ expect(CacheConversation.getItem(tag)).toBeFalsy();
97
+ });
98
+ });
99
+ });
100
+ });
101
+
102
+ describe("replies", function () {
103
+ beforeAll(function () {
104
+ conversation.ensure({payload: "second", issuedTime: 3, tag: 'z'});
105
+ conversation.ensure({payload: "deleted", issuedTime: 2, tag: 'y'});
106
+ conversation.ensure({payload: "first", issuedTime: 1, tag: 'x'});
107
+ conversation.ensure({payload: null, issuedTime: 4, tag: 'y'});
108
+ });
109
+ it("adds replies in timestamp order.", function () {
110
+ expect(conversation.items.map(reply => reply.payload)).toEqual(["first", "second"]);
111
+ });
112
+ it("removes deleted replies.", function () { // fixme eachReply
113
+ expect(conversation.items.find(reply => !reply.payload)).toBeFalsy();
114
+ });
115
+ });
116
+ });