@yz-social/kdht 0.1.0
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/README.md +31 -0
- package/dht/helper.js +25 -0
- package/dht/kbucket.js +83 -0
- package/dht/node.js +155 -0
- package/dht/nodeContacts.js +122 -0
- package/dht/nodeKeys.js +52 -0
- package/dht/nodeMessages.js +29 -0
- package/dht/nodeProbe.js +87 -0
- package/dht/nodeRefresh.js +60 -0
- package/dht/nodeStorage.js +23 -0
- package/dht/nodeTransports.js +59 -0
- package/dht/nodeUtilities.js +81 -0
- package/index.js +7 -0
- package/junx.js +17 -0
- package/package.json +46 -0
- package/portals/node.js +31 -0
- package/portals/router.js +67 -0
- package/routes/index.js +39 -0
- package/save/dhtWriteRead.js +48 -0
- package/save/webrtc.js +212 -0
- package/spec/bots.js +79 -0
- package/spec/dhtAcceptanceSpec.js +227 -0
- package/spec/dhtImplementation.js +191 -0
- package/spec/dhtInternalsSpec.js +253 -0
- package/spec/dhtKeySpec.js +92 -0
- package/spec/dhtWriteRead.js +56 -0
- package/spec/node.html +47 -0
- package/spec/portal.js +119 -0
- package/spec/support/jasmine.mjs +14 -0
- package/spec/writes.js +11 -0
- package/transports/contact.js +127 -0
- package/transports/inProcessWebrtc.js +70 -0
- package/transports/simulations.js +66 -0
- package/transports/webrtc.js +249 -0
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# KDHT
|
|
2
|
+
|
|
3
|
+
A Kademlia Distributed Hash Table
|
|
4
|
+
See [paper](https://www.scs.stanford.edu/~dm/home/papers/kpos.pdf) and [wikipedia](https://en.wikipedia.org/wiki/Kademlia)
|
|
5
|
+
|
|
6
|
+
This repo allows us to experiment with a pure kdht, to see the effects of changes and and optimizations.
|
|
7
|
+
For example, there is a class that implements connections to nodes running on the same computer, so that tests can be run without any networking at all (as long as the CPU isn't overwhelmed from running too many nodes).
|
|
8
|
+
There is a repeatable test suite that can be used to confirm behavior as changes are made.
|
|
9
|
+
|
|
10
|
+
### Classes
|
|
11
|
+
|
|
12
|
+
A [Node](./dht/node.js) is an actor in the DHT, and it has a key - a [BigInt](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt) of Node.keySize bits:
|
|
13
|
+
- A typical client will have one Node instance through which it interacts with one DHT.
|
|
14
|
+
- A server or simulation might have many Node instances to each interact with the same DHT.
|
|
15
|
+
A Node has a Contact object to represent itself to another Node.
|
|
16
|
+
A Node maintains [KBuckets](./dht/kbucket.js), which each have a list of Contacts to other Nodes.
|
|
17
|
+
|
|
18
|
+
A [Contact](./transports/contact.js) is the means through which a Node interacts with another Node instance:
|
|
19
|
+
- When sending an RPC request, the Contact will "serialize" the sender Nodes's contact.
|
|
20
|
+
- When receiving an RPC response, the sender "deserializes" a string (maybe using a cache) to produce the Contact instance to be noted in the receiver's KBuckets.
|
|
21
|
+
- In classic UDP Kademlia, a Contact would serialize as {key, ip, port}.
|
|
22
|
+
- In a simulation, a Contact could "serialize" as just itself.
|
|
23
|
+
- In our system, I imagine that it will serialize as signature so that keys cannot be forged.
|
|
24
|
+
|
|
25
|
+
While a Node maintains several Contacts in its KBuckets, these are organized based on the distance from the Contact's key to the Node's key. However, each network-probing operation requires the ephermal creation of Contact information that is based on the distance to the target key being probed for. For this purpose, we wrap the Contacts in a [Helper](./dht/helper.js) object that caches the distance to the target.
|
|
26
|
+
|
|
27
|
+
## TODO
|
|
28
|
+
|
|
29
|
+
- Get rid of sname. Unnecessary.
|
|
30
|
+
- Use a uniform RPC dispatcher everywhere, isntead of bespoke inFlight promises to await, and the like.
|
|
31
|
+
- In WebRTC, We wrap each signal in an array with a leading type: [['offer', offerObject], ...['icecandidate', iceCandidateObject]]. We don't need to do that, as the objects are all either of the form {type: 'offer', ..}, or {candidate: ...} (with no 'type' property).
|
package/dht/helper.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Node } from './node.js';
|
|
2
|
+
|
|
3
|
+
// A Contact that is some distance from an assumed targetKey.
|
|
4
|
+
export class Helper {
|
|
5
|
+
constructor(contact, distance) {
|
|
6
|
+
this.contact = contact;
|
|
7
|
+
this.distance = distance;
|
|
8
|
+
}
|
|
9
|
+
get key() { return this.contact.key; }
|
|
10
|
+
get name() { return this.contact.name; }
|
|
11
|
+
get node() { return this.contact.node; }
|
|
12
|
+
get report() { return this.contact.report; }
|
|
13
|
+
static compare = (a, b) => { // For sort, where a,b have a distance property returning a BigInt.
|
|
14
|
+
// Sort expects a number, so bigIntA - bigIntB won't do.
|
|
15
|
+
// This works for elements of a list that have a distance property -- they do not strictly have to be Helper instances.
|
|
16
|
+
if (a.distance < b.distance) return -1;
|
|
17
|
+
if (a.distance > b.distance) return 1;
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
static findClosest(targetKey, contacts, count = this.constructor.k) { // Utility, useful for computing and debugging.
|
|
21
|
+
const helpers = contacts.map(contact => new Helper(contact, contact.distance(targetKey)));
|
|
22
|
+
helpers.sort(this.compare);
|
|
23
|
+
return helpers.slice(0, count);
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dht/kbucket.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const { BigInt } = globalThis; // For linters.
|
|
2
|
+
|
|
3
|
+
// Bucket in a RoutingTable: a list of up to k Contacts as enforced by addContact().
|
|
4
|
+
export class KBucket {
|
|
5
|
+
constructor(node, index) {
|
|
6
|
+
this.node = node;
|
|
7
|
+
this.index = index;
|
|
8
|
+
|
|
9
|
+
// Cache the binary prefix used in randomTarget.
|
|
10
|
+
const keySize = node.constructor.keySize;
|
|
11
|
+
const nLeadingZeros = keySize - 1 - this.index;
|
|
12
|
+
// The next bit after the leading zeros must be one to stay in this bucket.
|
|
13
|
+
this.binaryPrefix = '0b' + '0'.repeat(nLeadingZeros) + '1';
|
|
14
|
+
this.resetRefresh();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
contacts = [];
|
|
18
|
+
get length() { // How many do we have (not capacity, which is k.)
|
|
19
|
+
return this.contacts.length;
|
|
20
|
+
}
|
|
21
|
+
get isFull() { // Are we at capacity?
|
|
22
|
+
return this.length >= this.node.constructor.k;
|
|
23
|
+
}
|
|
24
|
+
get nTransports() { // How many of our contacts have their own transport connection?
|
|
25
|
+
return this.contacts.reduce((accumulator, contact) => contact.connection ? accumulator + 1 : accumulator, 0);
|
|
26
|
+
}
|
|
27
|
+
get randomTarget() { // Return a key for which this.getBucketIndex will be the given bucketIndex.
|
|
28
|
+
const nodeClass = this.node.constructor;
|
|
29
|
+
const keySize = nodeClass.keySize;
|
|
30
|
+
let binary = this.binaryPrefix;
|
|
31
|
+
// Now fill the rest (if any) with random bits. -2 for the '0b' prefix.
|
|
32
|
+
for (let i = binary.length - 2; i < keySize; i++) binary += Math.round(Math.random());
|
|
33
|
+
const distance = BigInt(binary);
|
|
34
|
+
// Quirk of xor distance that it works backwards like this.
|
|
35
|
+
return this.node.distance(distance);
|
|
36
|
+
}
|
|
37
|
+
async refresh() { // Refresh specified bucket using LocateNodes for a random key in the specified bucket's range.
|
|
38
|
+
if (this.node.isStopped() || !this.contacts.length) return false; // fixme skip isStopped?
|
|
39
|
+
this.node.log('refresh bucket', this.index);
|
|
40
|
+
const targetKey = this.randomTarget;
|
|
41
|
+
await this.node.locateNodes(targetKey); // Side-effect is to update this bucket.
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
resetRefresh() { // We are organically performing a lookup in this bucket. Reset the timer.
|
|
45
|
+
// clearInterval(this.refreshTimer);
|
|
46
|
+
// this.refreshTimer = this.node.repeat(() => this.refresh(), 'bucket');
|
|
47
|
+
this.node.schedule(this.index, 'bucket', () => this.refresh());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
removeKey(key, deleteIfEmpty = true) { // Removes item specified by key (if present) from bucket and return 'present' if it was, else false.
|
|
51
|
+
const { contacts } = this;
|
|
52
|
+
let index = contacts.findIndex(item => item.key === key);
|
|
53
|
+
if (index !== -1) {
|
|
54
|
+
contacts.splice(index, 1);
|
|
55
|
+
// Subtle: ensures that if contact is later added, it will resetRefresh.
|
|
56
|
+
if (deleteIfEmpty && !contacts.length) this.node.routingTable.delete(this.index);
|
|
57
|
+
return 'present';
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async addContact(contact) { // Returns 'present' or 'added' if it was added to end within capacity, else false.
|
|
63
|
+
// Resets refresh timer.
|
|
64
|
+
this.node.constructor.assert(contact.node.key !== this.node.key, 'attempt to add self contact to bucket');
|
|
65
|
+
let added = this.removeKey(contact.key, false) || 'added';
|
|
66
|
+
//this.node.log('addContact', contact.name, this.index, added, this.isFull ? 'full' : '');
|
|
67
|
+
if (this.isFull) {
|
|
68
|
+
if (added === 'present') this.node.looseTransports.push(contact); // So no findContact will fail during ping. Should we instead serialize findContact?
|
|
69
|
+
const head = this.contacts[0];
|
|
70
|
+
if (await head.sendRPC('ping', head.key)) { // still alive
|
|
71
|
+
added = false; // New contact will not be added.
|
|
72
|
+
contact = head; // Add head back, below.
|
|
73
|
+
}
|
|
74
|
+
if (added === 'present') this.node.removeLooseTransport(contact.key);
|
|
75
|
+
// In either case (whether re-adding head to tail, or making room from a dead head), remove head now.
|
|
76
|
+
// Subtle: Don't remove before waiting for the ping, as there can be overlap with other activity that could
|
|
77
|
+
// think there's room and thus add it twice.
|
|
78
|
+
this.removeKey(head.key);
|
|
79
|
+
}
|
|
80
|
+
this.contacts.push(contact);
|
|
81
|
+
return added;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dht/node.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const { BigInt } = globalThis; // For linters.
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import { NodeProbe } from './nodeProbe.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The chain of superclasses are not really intended to be used separately.
|
|
7
|
+
They are just broken into smaller peices to make it easier to review the code.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// An actor within thin DHT.
|
|
11
|
+
export class Node extends NodeProbe {
|
|
12
|
+
// These are the public methods for applications.
|
|
13
|
+
|
|
14
|
+
async locateNodes(targetKey, number = this.constructor.k) { // Promise up to k best Contacts for targetKey (sorted closest first).
|
|
15
|
+
// Side effect is to discover other nodes (and they us).
|
|
16
|
+
targetKey = await this.ensureKey(targetKey);
|
|
17
|
+
return await this.iterate(targetKey, 'findNodes', number);
|
|
18
|
+
}
|
|
19
|
+
async locateValue(targetKey) { // Promise value stored for targetKey, or undefined.
|
|
20
|
+
// Side effect is to discover other nodes (and they us).
|
|
21
|
+
targetKey = await this.ensureKey(targetKey);
|
|
22
|
+
|
|
23
|
+
// Optimization: should still work without this, but then there are more RPCs.
|
|
24
|
+
const found = this.retrieveLocally(targetKey);
|
|
25
|
+
if (found !== undefined) return found;
|
|
26
|
+
|
|
27
|
+
const result = await this.iterate(targetKey, 'findValue');
|
|
28
|
+
if (Node.isValueResult(result)) return result.value;
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
async storeValue(targetKey, value) { // Convert targetKey to a bigint if necessary, and store k copies.
|
|
32
|
+
// Promises the number of nodes that it was stored on.
|
|
33
|
+
targetKey = await this.ensureKey(targetKey);
|
|
34
|
+
// Go until we are sure have written k.
|
|
35
|
+
const k = this.constructor.k;
|
|
36
|
+
let remaining = k;
|
|
37
|
+
// Ask for more, than needed, and then store to each, one at a time, until we
|
|
38
|
+
// have replicated k times.
|
|
39
|
+
let helpers = await this.locateNodes(targetKey, remaining * 2);
|
|
40
|
+
helpers = helpers.reverse(); // So we can save best-first by popping off the end.
|
|
41
|
+
// TODO: batches in parallel, if the client and network can handle it. (For now, better to spread it out.)
|
|
42
|
+
while (helpers.length && remaining) {
|
|
43
|
+
const contact = helpers.pop().contact;
|
|
44
|
+
const stored = await contact.store(targetKey, value);
|
|
45
|
+
if (stored) remaining--;
|
|
46
|
+
}
|
|
47
|
+
return k - remaining;
|
|
48
|
+
}
|
|
49
|
+
async join(contact) {
|
|
50
|
+
this.log('joining', contact.sname);
|
|
51
|
+
contact = this.ensureContact(contact);
|
|
52
|
+
await contact.connect();
|
|
53
|
+
await this.addToRoutingTable(contact);
|
|
54
|
+
await this.locateNodes(this.key); // Discovers between us and otherNode.
|
|
55
|
+
|
|
56
|
+
// Refresh every bucket farther out than our closest neighbor.
|
|
57
|
+
// I think(?) that this can be done by refreshing "just" the farthest bucket:
|
|
58
|
+
//this.ensureBucket(this.constructor.keySize - 1).resetRefresh();
|
|
59
|
+
await this.ensureBucket(this.constructor.keySize - 1).refresh();
|
|
60
|
+
// But if it turns out to be necessary to explicitly refresh each next bucket in turn, this is how:
|
|
61
|
+
// let started = false;
|
|
62
|
+
// for (let index = 0; index < this.constructor.keySize; index++) {
|
|
63
|
+
// // TODO: Do we really have to perform a refresh on EACH bucket? Won't a refresh of the farthest bucket update the closer ones?
|
|
64
|
+
// // TODO: Can it be in parallel?
|
|
65
|
+
// const bucket = this.routingTable.get(index);
|
|
66
|
+
// if (!bucket?.contacts.length && !started) continue;
|
|
67
|
+
// if (!started) started = true;
|
|
68
|
+
// else if (!bucket?.contacts.length) await this.ensureBucket(index).refresh();
|
|
69
|
+
// }
|
|
70
|
+
this.log('joined', contact.sname);
|
|
71
|
+
return this.contact; // Answering this node's home contact is handy for chaining or keeping track of contacts being made and joined.
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
// TODO: separate all this out: neither transport nor dht.
|
|
76
|
+
// TODO: fragment/reassemble big messages.
|
|
77
|
+
messagePromises = new Map(); // maps message messageTags => responders, separately from inFlight, above
|
|
78
|
+
async message({targetKey, targetSname, excluded = [], requestTag, senderKey, senderSname = this.contact.sname, payload}) { // Send message to targetKey, using our existing routingTable contacts.
|
|
79
|
+
//const MAX_PING_MS = 400;
|
|
80
|
+
const MAX_MESSAGE_MS = 2e3;
|
|
81
|
+
let responsePromise = null;
|
|
82
|
+
if (!requestTag) { // If this is an outgoing request from us, promises the response.
|
|
83
|
+
requestTag = uuidv4();
|
|
84
|
+
senderKey = this.key;
|
|
85
|
+
senderSname = this.contact.sname;
|
|
86
|
+
responsePromise = new Promise(resolve => this.messagePromises.set(requestTag, resolve));
|
|
87
|
+
}
|
|
88
|
+
targetKey = targetKey.toString();
|
|
89
|
+
senderKey = senderKey?.toString();
|
|
90
|
+
// The excluded list of keys prevents cycles.
|
|
91
|
+
excluded.push(this.key.toString()); // TODO: Find a way to not have an excluded list, or at least add junk for privacy.
|
|
92
|
+
const body = JSON.stringify({targetKey, excluded, requestTag, senderKey, targetSname, senderSname, payload});
|
|
93
|
+
const contacts = this.findClosestHelpers(BigInt(targetKey)).map(helper => helper.contact).filter(contact => contact.key !== this.key);
|
|
94
|
+
this.log('=>', targetSname, 'message', requestTag, 'contacts:', contacts.map(c => c.sname));
|
|
95
|
+
for (const contact of contacts) {
|
|
96
|
+
if (excluded.includes(contact.key.toString())) { this.log('skiping excluded', contact.sname, 'for message', requestTag); continue; }
|
|
97
|
+
//this.xlog('trying message through', contact.sname);
|
|
98
|
+
if (!contact.connection) { this.log('skipping unconnected', contact.sname, 'for message', requestTag); continue; }
|
|
99
|
+
if (!(await contact.sendRPC('ping', contact.key))) {
|
|
100
|
+
this.xlog('failed to get ping', contact.sname, 'for message', requestTag);
|
|
101
|
+
this.removeContact(contact);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
let overlay = await contact.overlay;
|
|
105
|
+
//this.xlog('hopping through', contact.sname, 'for message', requestTag, 'from', senderSname, 'to', targetSname);
|
|
106
|
+
overlay.send(body);
|
|
107
|
+
// FIXME: how do we know if a response was ultimately delivered? Don't we need a confirmation for that so that we can try a different route?
|
|
108
|
+
const result = await responsePromise;
|
|
109
|
+
//this.xlog('got result for message', requestTag, 'through', contact.sname);
|
|
110
|
+
//const result = await Promise.race([responsePromise, Node.delay(MAX_MESSAGE_MS, 'fixme')]);
|
|
111
|
+
// if (result === 'fixme') {
|
|
112
|
+
// this.xlog(`message timeout to ${targetSname} via ${contact.sname}.`);
|
|
113
|
+
// continue; // If we timeout, responsePromise is still valid.
|
|
114
|
+
// }
|
|
115
|
+
return result;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
this.xlog(`No connected contacts to send message ${requestTag} to ${targetSname} among ${contacts.map(c => `${excluded.includes(c.key.toString()) ? 'excluded/' : (c.connection ? '' : 'unconnected/')}${c.sname}`).join(', ')}`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
async messageHandler(dataString) {
|
|
122
|
+
//this.log('got overlay', dataString);
|
|
123
|
+
const data = JSON.parse(dataString);
|
|
124
|
+
const {targetKey, excluded, requestTag, senderKey, targetSname, senderSname, payload} = data;
|
|
125
|
+
Node.assert(targetKey, 'No targetKey to handle', dataString);
|
|
126
|
+
Node.assert(payload, 'No payload to handle', dataString);
|
|
127
|
+
Node.assert(requestTag, 'No request tag by which to answer', dataString);
|
|
128
|
+
this.log('handling message', requestTag, 'from', senderSname, 'to', targetSname);
|
|
129
|
+
|
|
130
|
+
// If intended for another node, pass it on.
|
|
131
|
+
if (BigInt(targetKey) !== this.key) return await this.message(data);
|
|
132
|
+
|
|
133
|
+
// If it is a response to something we sent, resolve the waiting promise with the payload.
|
|
134
|
+
const responder = this.messagePromises.get(requestTag);
|
|
135
|
+
this.messagePromises.delete(requestTag);
|
|
136
|
+
if (responder) return responder(payload);
|
|
137
|
+
|
|
138
|
+
// Finally, answer a request to us by messaging the sender with the same-tagged response.
|
|
139
|
+
const [method, ...args] = payload;
|
|
140
|
+
if (!(method in this)) return this.xlog('ignoring unrecognized request', {requestTag, method, args}); // Could be a double response.
|
|
141
|
+
|
|
142
|
+
Node.assert(args[0] === senderSname, 'FIXME sender does not match signals payload sender', dataString);
|
|
143
|
+
this.log('executing', method, 'from', senderSname, 'request:', requestTag);
|
|
144
|
+
let response = await this[method](...args);
|
|
145
|
+
Node.assert(senderKey, 'No sender key by which to answer', dataString);
|
|
146
|
+
this.log('responding to', method, 'from', senderSname, 'request:', requestTag);
|
|
147
|
+
return await this.message({targetKey: BigInt(senderKey), targetSname: senderSname, requestTag, payload: response});
|
|
148
|
+
}
|
|
149
|
+
async signal(...rest) {
|
|
150
|
+
//this.xlog(`handling signals @ ${this.key} ${rest}`);
|
|
151
|
+
const fixme = await this.contact.signals(...rest);
|
|
152
|
+
//this.xlog(`returning signals @ ${this.key} ${fixme}`);
|
|
153
|
+
return fixme;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { NodeTransports } from './nodeTransports.js';
|
|
2
|
+
import { Helper } from './helper.js';
|
|
3
|
+
import { KBucket } from './kbucket.js';
|
|
4
|
+
const { BigInt } = globalThis; // For linters.
|
|
5
|
+
|
|
6
|
+
// Management of Contacts (but see nodeTransports, too)
|
|
7
|
+
export class NodeContacts extends NodeTransports {
|
|
8
|
+
static k = 20; // Chosen so that for any k nodes, it is highly likely that at least one is still up after refreshTimeIntervalMS.
|
|
9
|
+
static commonPrefixLength(distance) { // Number of leading zeros of distance (within fixed keySize).
|
|
10
|
+
if (distance === this.zero) return this.keySize; // I.e., zero distance => our own Node => 128 (i.e., one past the farthest bucket).
|
|
11
|
+
|
|
12
|
+
let length = 0;
|
|
13
|
+
let mask = this.one << BigInt(this.keySize - 1);
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < this.keySize; i++) {
|
|
16
|
+
if ((distance & mask) !== this.zero) {
|
|
17
|
+
return length;
|
|
18
|
+
}
|
|
19
|
+
length++;
|
|
20
|
+
mask >>= this.one;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return this.keySize;
|
|
24
|
+
}
|
|
25
|
+
routingTable = new Map(); // Maps bit prefix length to KBucket
|
|
26
|
+
getBucketIndex(key) { // index of routingTable KBucket that should contain the given Node key.
|
|
27
|
+
// We define bucket 0 for the closest distance, and bucket (keySize - 1) for the farthest,
|
|
28
|
+
// as in the original paper. Note that some implementation and papers number these in the reverse order.
|
|
29
|
+
// Significantly, Wikipedia numbers these in the reverse order, AND it implies that the buckets
|
|
30
|
+
// represent addresses, when in fact they represent a distance from current node's address.
|
|
31
|
+
const distance = this.distance(key);
|
|
32
|
+
const prefixLength = this.constructor.commonPrefixLength(distance);
|
|
33
|
+
return 128 - prefixLength - 1;
|
|
34
|
+
}
|
|
35
|
+
ensureBucket(index) { // Return bucket at index, creating it if necessary.
|
|
36
|
+
const routingTable = this.routingTable;
|
|
37
|
+
let bucket = routingTable.get(index);
|
|
38
|
+
if (!bucket) {
|
|
39
|
+
bucket = new KBucket(this, index);
|
|
40
|
+
routingTable.set(index, bucket);
|
|
41
|
+
}
|
|
42
|
+
return bucket;
|
|
43
|
+
}
|
|
44
|
+
forEachBucket(iterator, reverse = false) { // Call iterator(bucket) on each non-empty bucket, stopping as soon as iterator(bucket) returns falsy.
|
|
45
|
+
let buckets = this.routingTable.values();
|
|
46
|
+
if (reverse) buckets = buckets.reverse();
|
|
47
|
+
for (const bucket of buckets) {
|
|
48
|
+
if (bucket && !iterator(bucket)) return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
get contacts() { // Answer a fresh copy of all contacts for this Node.
|
|
52
|
+
const contacts = [];
|
|
53
|
+
this.forEachBucket(bucket => {
|
|
54
|
+
contacts.push(...bucket.contacts);
|
|
55
|
+
return true; // Subtle: an empty bucket will return 0 from push(), which will stop iteration and miss later buckets.
|
|
56
|
+
});
|
|
57
|
+
return contacts;
|
|
58
|
+
}
|
|
59
|
+
contactDictionary = {}; // maps name => contact for lifetime of Node instance until removeContact.
|
|
60
|
+
existingContact(name) { // Returns contact with the given name for this node, without searching buckets or looseTransports.
|
|
61
|
+
return this.contactDictionary[name];
|
|
62
|
+
}
|
|
63
|
+
addExistingContact(contact) { // Adds to set of contactDictionary.
|
|
64
|
+
this.contactDictionary[contact.name] = contact;
|
|
65
|
+
}
|
|
66
|
+
findContact(match) { // Answer the contact for which match predicate is true, if any, whether in buckets or looseTransports. Does not remove it.
|
|
67
|
+
let contact = this.looseTransports.find(match);
|
|
68
|
+
if (contact) return contact;
|
|
69
|
+
this.forEachBucket(bucket => !(contact = bucket.contacts.find(match))); // Or we could compute index and look just there.
|
|
70
|
+
return contact;
|
|
71
|
+
}
|
|
72
|
+
findContactByKey(key) { // findContact matching the specified key. To be found, contact must be in routingTable or looseTransports (which is different from existingContact()).
|
|
73
|
+
return this.findContact(contact => contact.key === key);
|
|
74
|
+
}
|
|
75
|
+
ensureContact(contact, sponsor = null) { // Return existing contact, if any (including looseTransports), else clone a new one for this host. Set sponsor.
|
|
76
|
+
// Subtle: Contact clone uses existingContact (above) to reuse an existing contact on the host, if possible.
|
|
77
|
+
// This is vital for bookkeeping through connections and sponsorship.
|
|
78
|
+
contact = contact.clone(this);
|
|
79
|
+
contact.noteSponsor(sponsor);
|
|
80
|
+
return contact;
|
|
81
|
+
}
|
|
82
|
+
routingTableSerializer = Promise.resolve();
|
|
83
|
+
queueRoutingTableChange(thunk) { // Promise to resolve thunk() -- after all previous queued thunks have resolved.
|
|
84
|
+
return this.routingTableSerializer = this.routingTableSerializer.then(thunk);
|
|
85
|
+
}
|
|
86
|
+
removeContact(contact) { // Removes from node entirely if present, from looseTransports or bucket as necessary.
|
|
87
|
+
return this.queueRoutingTableChange(() => {
|
|
88
|
+
delete this.contactDictionary[contact.name];
|
|
89
|
+
const key = contact.key;
|
|
90
|
+
if (this.removeLooseTransport(key)) return;
|
|
91
|
+
const bucketIndex = this.getBucketIndex(key);
|
|
92
|
+
const bucket = this.routingTable.get(bucketIndex);
|
|
93
|
+
bucket?.removeKey(key); // Host might not yet have added node or anyone else as contact for that bucket yet, so maybe no bucket.
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
addToRoutingTable(contact) { // Promise contact, and add it to the routing table if room.
|
|
97
|
+
if (contact.key === this.key) return null; // Do not add self.
|
|
98
|
+
|
|
99
|
+
// In most cases there should be a connection, but it sometimes happens that by the time we get here,
|
|
100
|
+
// we have already dropped the connection.
|
|
101
|
+
//this.constructor.assert(contact.connection, 'Adding contact without connection', contact.report, 'in', this.contact.report);
|
|
102
|
+
|
|
103
|
+
return this.queueRoutingTableChange(async () => {
|
|
104
|
+
const bucketIndex = this.getBucketIndex(contact.key);
|
|
105
|
+
const bucket = this.ensureBucket(bucketIndex);
|
|
106
|
+
|
|
107
|
+
// Try to add to bucket
|
|
108
|
+
const added = await bucket.addContact(contact);
|
|
109
|
+
if (added !== 'present') { // Not already tracked in bucket.
|
|
110
|
+
this.removeLooseTransport(contact.key); // Can't be in two places.
|
|
111
|
+
this.queueWork(() => this.replicateCloserStorage(contact));
|
|
112
|
+
}
|
|
113
|
+
return added;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
findClosestHelpers(targetKey, count = this.constructor.k) { // Answer count closest Helpers to targetKey, including ourself.
|
|
117
|
+
if (!this.contact) return []; // Can happen while we are shutting down during a probe.
|
|
118
|
+
const contacts = this.contacts; // Always a fresh copy.
|
|
119
|
+
contacts.push(this.contact); // We are a candidate, too! TODO: Handle this separately in iterate so that we don't have to marshal our contacts.
|
|
120
|
+
return Helper.findClosest(targetKey, contacts, count);
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dht/nodeKeys.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NodeUtilities } from './nodeUtilities.js';
|
|
2
|
+
const { BigInt, TextEncoder, crypto } = globalThis; // For linters.
|
|
3
|
+
|
|
4
|
+
// A node name is (coerced to) a string, and a node key is BigInt of keySize.
|
|
5
|
+
export class NodeKeys extends NodeUtilities {
|
|
6
|
+
static zero = 0n;
|
|
7
|
+
static one = 1n;
|
|
8
|
+
static keySize = 128; // Number of bits in a key. Must be multiple of 8 and <= sha256.
|
|
9
|
+
static distance(keyA, keyB) { // xor
|
|
10
|
+
return keyA ^ keyB;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static async sha256(string) { // Promises a Uint8Array containing the hash of string.
|
|
14
|
+
const msgBuffer = new TextEncoder().encode(string);
|
|
15
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
|
16
|
+
const uint8Array = new Uint8Array(hashBuffer);
|
|
17
|
+
return uint8Array;
|
|
18
|
+
}
|
|
19
|
+
static uint8ArrayToHex(uint8Array) { // Answer uint8Array as a hex string.
|
|
20
|
+
return Array.from(uint8Array)
|
|
21
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
22
|
+
.join('');
|
|
23
|
+
}
|
|
24
|
+
static async key(string) { // If "string" is already a bigint, return it. Otherwise hash it to a keySize BigInt.
|
|
25
|
+
if (typeof(string) === 'bigint') return string;
|
|
26
|
+
const uint8Array = await this.sha256(string.toString());
|
|
27
|
+
const truncated = uint8Array.slice(0, this.keySize / 8);
|
|
28
|
+
const hex = this.uint8ArrayToHex(truncated);
|
|
29
|
+
const key = BigInt('0x' + hex);
|
|
30
|
+
return key;
|
|
31
|
+
}
|
|
32
|
+
static counter = 0; // If name is not given, use incremented counter.
|
|
33
|
+
static async create(nameOrProperties = {}) { // Create a node with a simple name and matching key.
|
|
34
|
+
if (['string', 'number'].includes(typeof nameOrProperties)) nameOrProperties = {name: nameOrProperties};
|
|
35
|
+
let {name = this.counter++, ...rest} = nameOrProperties;
|
|
36
|
+
name = name.toString();
|
|
37
|
+
const key = await this.key(name);
|
|
38
|
+
return new this({name, key, ...rest});
|
|
39
|
+
}
|
|
40
|
+
static fromKey(key) { // Forge specific key for testing. The key can be a BigInt or a string that will make a BigInt.
|
|
41
|
+
if (typeof(key) !== 'bigint') key = BigInt(key);
|
|
42
|
+
return new this({name: key.toString() + 'n', key});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
distance(targetKey) { // Distance from this node to targetKey.
|
|
46
|
+
return this.constructor.distance(this.key, targetKey);
|
|
47
|
+
}
|
|
48
|
+
async ensureKey(targetKey) { // If targetKey is not already a real key, hash it into one.
|
|
49
|
+
if (typeof(targetKey) !== 'bigint') targetKey = await this.constructor.key(targetKey);
|
|
50
|
+
return targetKey;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NodeContacts } from './nodeContacts.js';
|
|
2
|
+
|
|
3
|
+
// The four methods we recevieve through RPCs.
|
|
4
|
+
// These are not directly invoked by a Node on itself, but rather on other nodes
|
|
5
|
+
// through Contact sendRPC.
|
|
6
|
+
export class NodeMessages extends NodeContacts {
|
|
7
|
+
ping(key) { // Respond with 'pong'. (RPC mechanism doesn't call unless connected.)
|
|
8
|
+
return 'pong';
|
|
9
|
+
}
|
|
10
|
+
store(key, value) { // Tell the node to store key => value, returning truthy.
|
|
11
|
+
this.storeLocally(key, value);
|
|
12
|
+
return 'pong';
|
|
13
|
+
}
|
|
14
|
+
findNodes(key) { // Return k closest Contacts from routingTable.
|
|
15
|
+
// TODO: Currently, this answers a list of Helpers. For security, it should be changed to a list of serialized Contacts.
|
|
16
|
+
// I.e., send back a list of verifiable signatures and let the receiver verify and then compute the distances.
|
|
17
|
+
return this.findClosestHelpers(key);
|
|
18
|
+
}
|
|
19
|
+
findValue(key) { // Like findNodes, but if we have key stored, return {value} instead.
|
|
20
|
+
let value = this.retrieveLocally(key);
|
|
21
|
+
if (value !== undefined) return {value};
|
|
22
|
+
return this.findClosestHelpers(key);
|
|
23
|
+
}
|
|
24
|
+
receiveRPC(method, sender, ...rest) {
|
|
25
|
+
// The sender exists, so add it to the routing table, but give it a while to finish joining.
|
|
26
|
+
this.addToRoutingTable(sender);
|
|
27
|
+
return this[method](...rest);
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dht/nodeProbe.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Helper } from './helper.js';
|
|
2
|
+
import { NodeMessages } from './nodeMessages.js';
|
|
3
|
+
|
|
4
|
+
// Probe the network
|
|
5
|
+
export class NodeProbe extends NodeMessages {
|
|
6
|
+
// There are only three kinds of rpc results: 'pong', [...helper], {value: something}
|
|
7
|
+
static isValueResult(rpcResult) {
|
|
8
|
+
return rpcResult && rpcResult !== 'pong' && 'value' in rpcResult;
|
|
9
|
+
}
|
|
10
|
+
static isContactsResult(rpcResult) {
|
|
11
|
+
return Array.isArray(rpcResult);
|
|
12
|
+
}
|
|
13
|
+
async step(targetKey, finder, helper, keysSeen) {
|
|
14
|
+
// Get up to k previously unseen Helpers from helper, adding results to keysSeen.
|
|
15
|
+
const contact = helper.contact;
|
|
16
|
+
// this.log('step with', contact.sname);
|
|
17
|
+
let results = await contact.sendRPC(finder, targetKey);
|
|
18
|
+
if (!results) { // disconnected
|
|
19
|
+
this.log('removing unconnected contact', contact.sname);
|
|
20
|
+
await this.removeContact(contact);
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
await this.addToRoutingTable(contact); // Live node, so update bucket.
|
|
24
|
+
// this.log('step added contact', contact.sname);
|
|
25
|
+
if (this.constructor.isContactsResult(results)) { // Keep only those that we have not seen, and note the new ones we have.
|
|
26
|
+
results = results.filter(helper => !keysSeen.has(helper.key) && keysSeen.add(helper.key));
|
|
27
|
+
// Results are (helpers around) contacts. Set them up for this host, with contact as sponsor.
|
|
28
|
+
results = results.map(h => new Helper(this.ensureContact(h.contact, contact), h.distance));
|
|
29
|
+
}
|
|
30
|
+
return results;
|
|
31
|
+
}
|
|
32
|
+
static alpha = 3; // How many lookup requests are initially tried in parallel. If no progress, we repeat with up to k more.
|
|
33
|
+
async iterate(targetKey, finder, k = this.constructor.k, trace = false) {
|
|
34
|
+
// Promise a best-first list of k Helpers from the network, by repeatedly trying to improve our closest known by applying finder.
|
|
35
|
+
// But if any finder operation answer isValueResult, answer that instead.
|
|
36
|
+
|
|
37
|
+
if (targetKey !== this.key) {
|
|
38
|
+
const bucketIndex = this.getBucketIndex(targetKey);
|
|
39
|
+
const bucket = this.routingTable.get(bucketIndex);
|
|
40
|
+
// Subtle: if we don't have one now, but will after, refreshes will be rescheduled by KBucket constructor.
|
|
41
|
+
bucket?.resetRefresh();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Each iteration uses a bigger pool than asked for, because some will have disconnected.
|
|
45
|
+
let pool = this.findClosestHelpers(targetKey, 2*k); // The k best-first Helpers known so far, that we have NOT queried yet.
|
|
46
|
+
const isValueResult = this.constructor.isValueResult;
|
|
47
|
+
const alpha = Math.min(pool.length, this.constructor.alpha);
|
|
48
|
+
const keysSeen = new Set(pool.map(h => h.key)); // Every key we've seen at all (candidates and all responses).
|
|
49
|
+
keysSeen.add(this.key); // We might or might not be in our list of closest helpers, and we could be in someone else's.
|
|
50
|
+
let toQuery = pool.slice(0, alpha);
|
|
51
|
+
pool = pool.slice(alpha); // Yes, this could be done with splice instead of slice, above, but it makes things hard to trace.
|
|
52
|
+
let best = []; // The accumulated closest-first result.
|
|
53
|
+
while (toQuery.length && this.isRunning) { // Stop if WE disconnect.
|
|
54
|
+
let requests = toQuery.map(helper => this.step(targetKey, finder, helper, keysSeen));
|
|
55
|
+
let results = await Promise.all(requests);
|
|
56
|
+
if (trace) this.log(toQuery.map(h => h.name), '=>', results.map(r => r.map?.(h => h.name) || r));
|
|
57
|
+
|
|
58
|
+
let found = results.find(isValueResult); // Did we get back a 'findValue' result.
|
|
59
|
+
if (found) {
|
|
60
|
+
// Store at closest result that didn't have it (if any). This can cause more than k copies in the network.
|
|
61
|
+
for (let i = 0; i < toQuery.length; i++) {
|
|
62
|
+
if (!isValueResult(results[i])) {
|
|
63
|
+
toQuery[i].contact.store(targetKey, found.value);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let closer = [].concat(...results); // Flatten results.
|
|
71
|
+
// closer might not be in order, and one or more toQuery might belong among them.
|
|
72
|
+
best = [...closer, ...toQuery, ...best].sort(Helper.compare).slice(0, k);
|
|
73
|
+
if (!closer.length) {
|
|
74
|
+
if (toQuery.length === alpha && pool.length) {
|
|
75
|
+
toQuery = pool.slice(0, 2*k); // Try again with k more. (Interestingly, not k - alpha.)
|
|
76
|
+
pool = pool.slice(2*k);
|
|
77
|
+
} else break; // We've tried everything and there's nothing better.
|
|
78
|
+
} else {
|
|
79
|
+
pool = [...closer, ...pool].slice(0, 2*k); // k best-first nodes that we have not queried.
|
|
80
|
+
toQuery = pool.slice(0, alpha);
|
|
81
|
+
pool = pool.slice(alpha);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (trace) this.log('probe result', best.map(helper => helper.name));
|
|
85
|
+
return best;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { NodeKeys } from './nodeKeys.js';
|
|
2
|
+
|
|
3
|
+
// The mechanics of periodic refresh (of buckets or stored data).
|
|
4
|
+
export class NodeRefresh extends NodeKeys {
|
|
5
|
+
static refreshTimeIntervalMS = 15e3; // Original paper for desktop filesharing was 60 minutes.
|
|
6
|
+
constructor ({refreshTimeIntervalMS = NodeRefresh.refreshTimeIntervalMS, ...properties}) {
|
|
7
|
+
super({refreshTimeIntervalMS, ...properties});
|
|
8
|
+
}
|
|
9
|
+
static stopRefresh() { // Stop all repeat timers in all instances the next time they come around.
|
|
10
|
+
this.constructor.refreshTimeIntervalMS = 0;
|
|
11
|
+
}
|
|
12
|
+
stopRefresh() { // Stop repeat timeers in this instance.
|
|
13
|
+
this.refreshTimeIntervalMS = 0;
|
|
14
|
+
}
|
|
15
|
+
isStopped(interval) {
|
|
16
|
+
return !this.isRunning || 0 === this.refreshTimeIntervalMS || 0 === this.constructor.refreshTimeIntervalMS || 0 === interval;
|
|
17
|
+
}
|
|
18
|
+
fuzzyInterval(target = this.refreshTimeIntervalMS/2, margin = target/2) { // Like static fuzzyInterval with target defaulting to refreshTimeIntervalMS/2.
|
|
19
|
+
return this.constructor.fuzzyInterval(target, margin);
|
|
20
|
+
}
|
|
21
|
+
static fuzzyInterval(target, margin = target/2) {
|
|
22
|
+
// Answer a random integer uniformly distributed around target, +/- margin.
|
|
23
|
+
// The default target slightly exceeds the Nyquist condition of sampling at a frequency at
|
|
24
|
+
// least twice the signal being observed. In particular, allowing for some randomization,
|
|
25
|
+
// as long as we're not completely overloaded, we should expect the target to hit at least
|
|
26
|
+
// once for each thing it is trying to detect, and generally happen twice for each detectable event.
|
|
27
|
+
const adjustment = this.randomInteger(margin);
|
|
28
|
+
return Math.floor(target + margin/2 - adjustment);
|
|
29
|
+
}
|
|
30
|
+
workQueue = Promise.resolve();
|
|
31
|
+
queueWork(thunk) { // Promise to resolve thunk() -- after all previous queued thunks have resolved.
|
|
32
|
+
return this.workQueue = this.workQueue.then(thunk);
|
|
33
|
+
}
|
|
34
|
+
timers = new Map();
|
|
35
|
+
schedule(timerKey, statisticsKey, thunk) {
|
|
36
|
+
// Schedule thunk() to occur at a fuzzyInterval from now, cancelling any
|
|
37
|
+
// existing timer at the same key. This is used in such a way that:
|
|
38
|
+
// 1. A side effect of calling thunk() is that it will be scheduled again, if appropriate.
|
|
39
|
+
// E.g., A bucket refresh calls iterate, which schedules, and storeValue schedules.
|
|
40
|
+
// 2. The timerKeys are treated appropriately under Map.
|
|
41
|
+
// E.g., bucket index 1 === 1 and stored value key BigInt(1) === BigInt(1), but 1 !== BigInt(1)
|
|
42
|
+
if (this.isStopped()) return;
|
|
43
|
+
const start = Date.now();
|
|
44
|
+
const timeout = this.fuzzyInterval();
|
|
45
|
+
clearInterval(this.timers.get(timerKey));
|
|
46
|
+
this.timers.set(timerKey, setTimeout(async () => {
|
|
47
|
+
const lag = Date.now() - start - timeout;
|
|
48
|
+
this.timers.delete(timerKey);
|
|
49
|
+
if (this.isStopped()) return;
|
|
50
|
+
if (lag > 250) console.log(`** System is overloaded by ${lag.toLocaleString()} ms. **`);
|
|
51
|
+
// Each actual thunk execution is serialized: Each Node executes its OWN various refreshes and probes
|
|
52
|
+
// one at a time. This prevents a node from self-DoS'ing, but of course it does not coordinate across
|
|
53
|
+
// nodes. If the system is bogged down for any reason, then the timeout spacing will get smaller
|
|
54
|
+
// until finally the node is just running flat out.
|
|
55
|
+
// this.log('queue', statisticsKey, timerKey, timeout);
|
|
56
|
+
await this.queueWork(thunk);
|
|
57
|
+
}, timeout));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|