| … | … | @@ -0,0 +1,70 @@ |
| 1 | // The JS CONTROL for test_daemon_longlived.py. | |
| 2 | // | |
| 3 | // Same shape as session-daemon: join one topic on the public DHT and report | |
| 4 | // `peers=N` on a pulse, so the SAME Python driver and the SAME accounting can | |
| 5 | // measure both implementations. The point is not to test hyperswarm — it is to | |
| 6 | // find out what the public DHT's own connect-failure rate looks like, so our | |
| 7 | // number has something to be compared against. | |
| 8 | // | |
| 9 | // node js_peer.js --topic <seed> [--seconds N] | |
| 10 | // | |
| 11 | // The topic is BLAKE2b(seed), exactly as daemon/main.cpp derives it, so a JS | |
| 12 | // peer and a C++ daemon on the same seed land on the same topic. | |
| 13 | ||
| 14 | const Hyperswarm = require('hyperswarm') | |
| 15 | const sodium = require('sodium-universal') | |
| 16 | const b4a = require('b4a') | |
| 17 | ||
| 18 | const argv = process.argv.slice(2) | |
| 19 | function arg (name, dflt) { | |
| 20 | const i = argv.indexOf(name) | |
| 21 | return i === -1 ? dflt : argv[i + 1] | |
| 22 | } | |
| 23 | ||
| 24 | const seed = arg('--topic', null) | |
| 25 | const seconds = Number(arg('--seconds', '0')) | |
| 26 | if (!seed) { | |
| 27 | console.error('usage: node js_peer.js --topic <seed> [--seconds N]') | |
| 28 | process.exit(1) | |
| 29 | } | |
| 30 | ||
| 31 | const topic = b4a.alloc(32) | |
| 32 | sodium.crypto_generichash(topic, b4a.from(seed)) | |
| 33 | ||
| 34 | const swarm = new Hyperswarm() | |
| 35 | let peers = 0 | |
| 36 | ||
| 37 | swarm.on('connection', (conn) => { | |
| 38 | peers++ | |
| 39 | pulse() | |
| 40 | conn.on('error', () => {}) | |
| 41 | conn.on('close', () => { peers--; pulse() }) | |
| 42 | // Say something, so the connection is real rather than merely opened. | |
| 43 | conn.write(b4a.from('hello')) | |
| 44 | conn.on('data', () => {}) | |
| 45 | }) | |
| 46 | ||
| 47 | function pulse () { | |
| 48 | const dht = swarm.dht | |
| 49 | const up = dht && dht.bootstrapped ? 'up' : 'connecting' | |
| 50 | console.log(`[js] dht=${up} peers=${peers}`) | |
| 51 | } | |
| 52 | ||
| 53 | async function main () { | |
| 54 | await swarm.dht.ready() | |
| 55 | pulse() | |
| 56 | swarm.join(topic, { server: true, client: true }) | |
| 57 | await swarm.flush() | |
| 58 | pulse() | |
| 59 | ||
| 60 | const timer = setInterval(pulse, 5000) | |
| 61 | if (seconds > 0) { | |
| 62 | setTimeout(async () => { | |
| 63 | clearInterval(timer) | |
| 64 | await swarm.destroy() | |
| 65 | process.exit(0) | |
| 66 | }, seconds * 1000) | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | main().catch((err) => { console.error(err); process.exit(1) }) |
| … | … | @@ -36,6 +36,15 @@ import time |
| 36 | 36 | HERE = os.path.dirname(os.path.realpath(__file__)) |
| 37 | 37 | DAEMON = os.path.join(HERE, "session-daemon") |
| 38 | 38 | |
| 39 | # ⛔ THE JS CONTROL. Our own pass rate means nothing on its own: the public DHT | |
| 40 | # has a failure rate of its own, and without measuring it we cannot tell an | |
| 41 | # ordinary flaky lookup from a defect of ours. `--js` runs the IDENTICAL driver | |
| 42 | # and the IDENTICAL accounting against hyperswarm 4.17.1 instead of | |
| 43 | # session-daemon, so the two numbers are comparable by construction. | |
| 44 | USE_JS = "--js" in sys.argv | |
| 45 | JS_PEER = os.path.join(HERE, "js_peer.js") | |
| 46 | NODE_MODULES = os.path.normpath(os.path.join(HERE, "..", "test", "node_modules")) | |
| 47 | ||
| 39 | 48 | PEERS = 20 # must exceed the 16-slot pool |
| 40 | 49 | BASELINE = 5 # of the first 8, at least this many must connect |
| 41 | 50 | CONNECT_TIMEOUT = 45.0 |
| … | … | @@ -45,11 +54,16 @@ EXIT_PASS, EXIT_FAIL, EXIT_INCONCLUSIVE = 0, 1, 2 |
| 45 | 54 | |
| 46 | 55 | |
| 47 | 56 | def spawn(topic, tmp, tag): |
| 48 | sock = os.path.join(tmp, "%s.sock" % tag) | |
| 49 | 57 | logpath = os.path.join(tmp, "%s.log" % tag) |
| 50 | 58 | log = open(logpath, "wb") |
| 51 | p = subprocess.Popen([DAEMON, "--topic", topic, "--socket", sock], | |
| 52 | stdout=log, stderr=subprocess.STDOUT) | |
| 59 | if USE_JS: | |
| 60 | env = dict(os.environ, NODE_PATH=NODE_MODULES) | |
| 61 | cmd = ["node", JS_PEER, "--topic", topic] | |
| 62 | p = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT, env=env) | |
| 63 | else: | |
| 64 | sock = os.path.join(tmp, "%s.sock" % tag) | |
| 65 | cmd = [DAEMON, "--topic", topic, "--socket", sock] | |
| 66 | p = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT) | |
| 53 | 67 | return p, log, logpath |
| 54 | 68 | |
| 55 | 69 | |
| … | … | @@ -82,15 +96,17 @@ def stop(proc, log): |
| 82 | 96 | |
| 83 | 97 | |
| 84 | 98 | def main(): |
| 85 | if not os.path.isfile(DAEMON): | |
| 86 | print("missing %s — run daemon/build.sh" % DAEMON) | |
| 99 | target = JS_PEER if USE_JS else DAEMON | |
| 100 | if not os.path.isfile(target): | |
| 101 | print("missing %s%s" % (target, "" if USE_JS else " — run daemon/build.sh")) | |
| 87 | 102 | return EXIT_INCONCLUSIVE |
| 88 | 103 | |
| 89 | 104 | topic = "pool-drain-%d" % os.getpid() |
| 90 | 105 | tmp = tempfile.mkdtemp(prefix="longlived-") |
| 91 | 106 | |
| 92 | 107 | print("=" * 78) |
| 93 | print("a long-lived session-daemon on the PUBLIC DHT, %d sequential peers" % PEERS) | |
| 108 | print("a long-lived %s on the PUBLIC DHT, %d sequential peers" | |
| 109 | % ("hyperswarm 4.17.1 (JS CONTROL)" if USE_JS else "session-daemon", PEERS)) | |
| 94 | 110 | print("=" * 78) |
| 95 | 111 | print("topic: %s" % topic) |
| 96 | 112 | print("logs: %s" % tmp) |
| … | … | @@ -133,6 +149,13 @@ def main(): |
| 133 | 149 | % (sum(early), len(early))) |
| 134 | 150 | return EXIT_INCONCLUSIVE |
| 135 | 151 | |
| 152 | if USE_JS: | |
| 153 | # The control measures the network, it does not gate our code. | |
| 154 | print("\nCONTROL: this is hyperswarm's own rate on the public DHT " | |
| 155 | "today. Compare it with the session-daemon run before reading " | |
| 156 | "anything into ours.") | |
| 157 | return EXIT_PASS | |
| 158 | ||
| 136 | 159 | if pool_full: |
| 137 | 160 | print("\nFAIL: the host exhausted its handshake pool — #72's wall, " |
| 138 | 161 | "unfixed.") |
| … | … | @@ -895,3 +895,37 @@ avoiding interference with the older `join_topic`/`connect_to_peer` API. A |
| 895 | 895 | deliberate deviation, not a defect. |
| 896 | 896 | |
| 897 | 897 | Suite: 13 binaries green. |
| 898 | ||
| 899 | ## 14. Slice 10 — the JS CONTROL: our connect-failure rate is the network's | |
| 900 | ||
| 901 | A pass rate means nothing on its own. Two valid session-daemon runs gave 19/20 | |
| 902 | and 16/20 — a ~15% failure rate — and there was no way to tell an ordinary flaky | |
| 903 | public-DHT lookup from a defect of ours. | |
| 904 | ||
| 905 | So the driver gained a `--js` flag that runs the IDENTICAL accounting against | |
| 906 | hyperswarm 4.17.1 (`daemon/js_peer.js`), which derives the topic as | |
| 907 | `BLAKE2b(seed)` exactly as `daemon/main.cpp` does. Same code path, same | |
| 908 | thresholds, same clock — only the peer implementation differs. | |
| 909 | ||
| 910 | hyperswarm 4.17.1 18/20 90% failures at 19, 20 | |
| 911 | session-daemon 19/20 95% failure at 20 | |
| 912 | session-daemon 16/20 80% failures at 13, 16, 18, 20 | |
| 913 | ||
| 914 | ours combined 35/40 87.5% vs JS 18/20 90% | |
| 915 | ||
| 916 | **Indistinguishable.** The public DHT drops roughly one connection in ten at this | |
| 917 | cadence and we match the reference. The ~15% was never ours to fix. | |
| 918 | ||
| 919 | ⚠ **n = 20 per run.** 90% vs 87.5% is well inside binomial noise. This rules out | |
| 920 | a GROSS defect — we are not at 50% while JS is at 95% — and rules out nothing | |
| 921 | smaller. A few percent would need hundreds of trials and a different harness. | |
| 922 | ||
| 923 | ⚠ JS's two failures were the LAST two peers; ours were scattered. Recorded, not | |
| 924 | interpreted: at one JS run that is not evidence of anything. | |
| 925 | ||
| 926 | ⛔ The `--js` path returns PASS unconditionally with a CONTROL note. A control | |
| 927 | measures the NETWORK; letting it gate our build would be a category error. | |
| 928 | ||
| 929 | **Why this belongs in a parity audit:** it is the only measurement here that | |
| 930 | could have said "stop looking". Every other slice found something. This one | |
| 931 | found that the thing I was about to chase was the DHT behaving normally. |
| … | … | @@ -377,6 +377,21 @@ |
| 377 | 377 | "b4a": "^1.3.0" |
| 378 | 378 | } |
| 379 | 379 | }, |
| 380 | "node_modules/hyperswarm": { | |
| 381 | "version": "4.17.1", | |
| 382 | "resolved": "https://registry.npmjs.org/hyperswarm/-/hyperswarm-4.17.1.tgz", | |
| 383 | "integrity": "sha512-bFy89nulBSY1qQZMZMGWb6wwBHftthONZr5IpX4o7hcSmMQI5IHyfA7Id3KU03SDflWfPvEm1YJy0AL5BFFblA==", | |
| 384 | "license": "MIT", | |
| 385 | "dependencies": { | |
| 386 | "b4a": "^1.3.1", | |
| 387 | "bare-events": "^2.2.0", | |
| 388 | "hyperdht": "^6.21.0", | |
| 389 | "safety-catch": "^1.0.2", | |
| 390 | "shuffled-priority-queue": "^2.1.0", | |
| 391 | "streamx": "^2.22.1", | |
| 392 | "unslab": "^1.3.0" | |
| 393 | } | |
| 394 | }, | |
| 380 | 395 | "node_modules/is-property": { |
| 381 | 396 | "version": "1.0.2", |
| 382 | 397 | "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", |
| … | … | @@ -472,6 +487,15 @@ |
| 472 | 487 | "integrity": "sha512-C1UYVZ4dtbBxEtvOcpjBaaD27nP8MlvyAQEp2fOTOEe6pfUpk1cDUxij6BR1jZup6rSyUTaBBplK7LanskrULA==", |
| 473 | 488 | "license": "MIT" |
| 474 | 489 | }, |
| 490 | "node_modules/shuffled-priority-queue": { | |
| 491 | "version": "2.1.0", | |
| 492 | "resolved": "https://registry.npmjs.org/shuffled-priority-queue/-/shuffled-priority-queue-2.1.0.tgz", | |
| 493 | "integrity": "sha512-xhdh7fHyMsr0m/w2kDfRJuBFRS96b9l8ZPNWGaQ+PMvnUnZ/Eh+gJJ9NsHBd7P9k0399WYlCLzsy18EaMfyadA==", | |
| 494 | "license": "MIT", | |
| 495 | "dependencies": { | |
| 496 | "unordered-set": "^2.0.1" | |
| 497 | } | |
| 498 | }, | |
| 475 | 499 | "node_modules/signal-promise": { |
| 476 | 500 | "version": "1.0.3", |
| 477 | 501 | "resolved": "https://registry.npmjs.org/signal-promise/-/signal-promise-1.0.3.tgz", |
| … | … | @@ -574,6 +598,12 @@ |
| 574 | 598 | "bare": ">=1.17.4" |
| 575 | 599 | } |
| 576 | 600 | }, |
| 601 | "node_modules/unordered-set": { | |
| 602 | "version": "2.0.1", | |
| 603 | "resolved": "https://registry.npmjs.org/unordered-set/-/unordered-set-2.0.1.tgz", | |
| 604 | "integrity": "sha512-eUmNTPzdx+q/WvOHW0bgGYLWvWHNT3PTKEQLg0MAQhc0AHASHVHoP/9YytYd4RBVariqno/mEUhVZN98CmD7bg==", | |
| 605 | "license": "MIT" | |
| 606 | }, | |
| 577 | 607 | "node_modules/unslab": { |
| 578 | 608 | "version": "1.3.0", |
| 579 | 609 | "resolved": "https://registry.npmjs.org/unslab/-/unslab-1.3.0.tgz", |
| … | … | @@ -0,0 +1,21 @@ |
| 1 | The MIT License (MIT) | |
| 2 | ||
| 3 | Copyright (c) 2019 Mathias Buus, Paul Frazee, David Mark Clements and contributors | |
| 4 | ||
| 5 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | of this software and associated documentation files (the "Software"), to deal | |
| 7 | in the Software without restriction, including without limitation the rights | |
| 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | copies of the Software, and to permit persons to whom the Software is | |
| 10 | furnished to do so, subject to the following conditions: | |
| 11 | ||
| 12 | The above copyright notice and this permission notice shall be included in all | |
| 13 | copies or substantial portions of the Software. | |
| 14 | ||
| 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | SOFTWARE. |
| … | … | @@ -0,0 +1,199 @@ |
| 1 | # hyperswarm | |
| 2 | ||
| 3 | ### [See the full API docs at docs.pears.com](https://docs.pears.com/building-blocks/hyperswarm) | |
| 4 | ||
| 5 | A high-level API for finding and connecting to peers who are interested in a "topic." | |
| 6 | ||
| 7 | ## Installation | |
| 8 | ||
| 9 | ``` | |
| 10 | npm install hyperswarm | |
| 11 | ``` | |
| 12 | ||
| 13 | ## Usage | |
| 14 | ||
| 15 | ```js | |
| 16 | const Hyperswarm = require('hyperswarm') | |
| 17 | ||
| 18 | const swarm = new Hyperswarm() | |
| 19 | ||
| 20 | swarm1.on('connection', (conn) => { | |
| 21 | // swarm1 will receive server connections | |
| 22 | conn.write('this is a server connection') | |
| 23 | conn.end() | |
| 24 | }) | |
| 25 | ||
| 26 | const discoveryKey = Buffer.alloc(32).fill('hello world') // must be 32 bytes | |
| 27 | ||
| 28 | // join the swarm, others will find you | |
| 29 | swarm2.join(discoveryKey, { server: true, client: true }) | |
| 30 | ``` | |
| 31 | ||
| 32 | ## Hyperswarm API | |
| 33 | ||
| 34 | #### `const swarm = new Hyperswarm(opts = {})` | |
| 35 | ||
| 36 | Construct a new Hyperswarm instance. | |
| 37 | ||
| 38 | `opts` can include: | |
| 39 | ||
| 40 | - `keyPair`: A Noise keypair that will be used to listen/connect on the DHT. Defaults to a new key pair. | |
| 41 | - `seed`: A unique, 32-byte, random seed that can be used to deterministically generate the key pair. | |
| 42 | - `maxPeers`: The maximum number of peer connections to allow. | |
| 43 | - `firewall`: A sync function of the form `remotePublicKey => (true|false)`. If true, the connection will be rejected. Defaults to allowing all connections. | |
| 44 | - `dht`: A DHT instance. Defaults to a new instance. | |
| 45 | ||
| 46 | #### `swarm.connecting` | |
| 47 | ||
| 48 | Number that indicates connections in progress. | |
| 49 | ||
| 50 | #### `swarm.connections` | |
| 51 | ||
| 52 | A set of all active client/server connections. | |
| 53 | ||
| 54 | #### `swarm.peers` | |
| 55 | ||
| 56 | A Map containing all connected peers, of the form: `(Noise public key hex string) -> PeerInfo object` | |
| 57 | ||
| 58 | See the [`PeerInfo`](#peerinfo-api) API for more details. | |
| 59 | ||
| 60 | #### `swarm.dht` | |
| 61 | ||
| 62 | A [`hyperdht`](https://github.com/holepunchto/hyperdht) instance. Useful if you want lower-level control over Hyperswarm's networking. | |
| 63 | ||
| 64 | #### `swarm.on('connection', (socket, peerInfo) => {})` | |
| 65 | ||
| 66 | Emitted whenever the swarm connects to a new peer. | |
| 67 | ||
| 68 | `socket` is an end-to-end (Noise) encrypted Duplex stream | |
| 69 | ||
| 70 | `peerInfo` is a [`PeerInfo`](#peerinfo-api) instance | |
| 71 | ||
| 72 | #### `swarm.on('update', () => {})` | |
| 73 | ||
| 74 | Emitted when internal values are changed, useful for user interfaces. | |
| 75 | ||
| 76 | For example: emitted when `swarm.connecting` or `swarm.connections` changes. | |
| 77 | ||
| 78 | #### `const discovery = swarm.join(topic, opts = {})` | |
| 79 | ||
| 80 | Start discovering and connecting to peers sharing a common topic. As new peers are connected to, they will be emitted from the swarm as `connection` events. | |
| 81 | ||
| 82 | `topic` must be a 32-byte Buffer | |
| 83 | `opts` can include: | |
| 84 | ||
| 85 | - `server`: Accept server connections for this topic by announcing yourself to the DHT. Defaults to `true`. | |
| 86 | - `client`: Actively search for and connect to discovered servers. Defaults to `true`. | |
| 87 | - `limit`: Set the max number of peers to connect to when joining the topic. Defaults to `Infinity`. | |
| 88 | ||
| 89 | Returns a [`PeerDiscovery`](#peerdiscovery-api) object. | |
| 90 | ||
| 91 | #### Clients and Servers | |
| 92 | ||
| 93 | In Hyperswarm, there are two ways for peers to join the swarm: client mode and server mode. If you've previously used Hyperswarm v2, these were called "lookup" and "announce", but we now think "client" and "server" are more descriptive. | |
| 94 | ||
| 95 | When you join a topic as a server, the swarm will start accepting incoming connections from clients (peers that have joined the same topic in client mode). Server mode will announce your keypair to the DHT, so that other peers can discover your server. When server connections are emitted, they are not associated with a specific topic -- the server only knows it received an incoming connection. | |
| 96 | ||
| 97 | When you join a topic as a client, the swarm will do a query to discover available servers, and will eagerly connect to them. As with server mode, these connections will be emitted as `connection` events, but in client mode they **will** be associated with the topic (`info.topics` will be set in the `connection` event). | |
| 98 | ||
| 99 | #### `await swarm.leave(topic)` | |
| 100 | ||
| 101 | Stop discovering peers for the given topic. | |
| 102 | ||
| 103 | `topic` must be a 32-byte Buffer | |
| 104 | ||
| 105 | If a topic was previously joined in server mode, `leave` will stop announcing the topic on the DHT. If a topic was previously joined in client mode, `leave` will stop searching for servers announcing the topic. | |
| 106 | ||
| 107 | `leave` will **not** close any existing connections. | |
| 108 | ||
| 109 | #### `swarm.joinPeer(noisePublicKey)` | |
| 110 | ||
| 111 | Establish a direct connection to a known peer. | |
| 112 | ||
| 113 | `noisePublicKey` must be a 32-byte Buffer | |
| 114 | ||
| 115 | As with the standard `join` method, `joinPeer` will ensure that peer connections are reestablished in the event of failures. | |
| 116 | ||
| 117 | #### `swarm.leavePeer(noisePublicKey)` | |
| 118 | ||
| 119 | Stop attempting direct connections to a known peer. | |
| 120 | ||
| 121 | `noisePublicKey` must be a 32-byte Buffer | |
| 122 | ||
| 123 | If a direct connection is already established, that connection will **not** be destroyed by `leavePeer`. | |
| 124 | ||
| 125 | #### `const discovery = swarm.status(topic)` | |
| 126 | ||
| 127 | Get the [`PeerDiscovery`](#peerdiscovery-api) object associated with the topic, if it exists. | |
| 128 | ||
| 129 | #### `await swarm.listen()` | |
| 130 | ||
| 131 | Explicitly start listening for incoming connections. This will be called internally after the first `join`, so it rarely needs to be called manually. | |
| 132 | ||
| 133 | #### `await swarm.flush()` | |
| 134 | ||
| 135 | Wait for any pending DHT announces, and for the swarm to connect to any pending peers (peers that have been discovered, but are still in the queue awaiting processing). | |
| 136 | ||
| 137 | Once a `flush()` has completed, the swarm will have connected to every peer it can discover from the current set of topics it's managing. | |
| 138 | ||
| 139 | `flush()` is not topic-specific, so it will wait for every pending DHT operation and connection to be processed -- it's quite heavyweight, so it could take a while. In most cases, it's not necessary, as connections are emitted by `swarm.on('connection')` immediately after they're opened. | |
| 140 | ||
| 141 | #### `await swarm.suspend({ log: () => {} })` | |
| 142 | ||
| 143 | Suspend the swarm disconnecting all peers, suspends server listening and stops discovery of new peers. Useful for suspending when the runtime suspends to pause networking. | |
| 144 | ||
| 145 | `log` is a logging function, which defaults to a noop function. | |
| 146 | ||
| 147 | #### `await swarm.resume({ log: () => {} })` | |
| 148 | ||
| 149 | Resume a suspended swarm refreshing discovery of new peers and servers. Useful for reannouncing to the DHT and reconnecting to peers when the runtime resumes. | |
| 150 | ||
| 151 | `log` is a logging function, which defaults to a noop function. | |
| 152 | ||
| 153 | #### `swarm.on('ban', peerInfo, err)` | |
| 154 | ||
| 155 | Emitted when a peer gets banned. `err` is an error object describing the reason for the ban (e.g. firewalled). | |
| 156 | ||
| 157 | ## PeerDiscovery API | |
| 158 | ||
| 159 | `swarm.join` returns a `PeerDiscovery` instance which allows you to both control discovery behavior, and respond to lifecycle changes during discovery. | |
| 160 | ||
| 161 | #### `await discovery.flushed()` | |
| 162 | ||
| 163 | Wait until the topic has been fully announced to the DHT. This method is only relevant in server mode. When `flushed()` has completed, the server will be available to the network. | |
| 164 | ||
| 165 | #### `await discovery.refresh({ client, server })` | |
| 166 | ||
| 167 | Update the `PeerDiscovery` configuration, optionally toggling client and server modes. This will also trigger an immediate re-announce of the topic, when the `PeerDiscovery` is in server mode. | |
| 168 | ||
| 169 | #### `await discovery.destroy()` | |
| 170 | ||
| 171 | Stop discovering peers for the given topic. | |
| 172 | ||
| 173 | If a topic was previously joined in server mode, `leave` will stop announcing the topic on the DHT. If a topic was previously joined in client mode, `leave` will stop searching for servers announcing the topic. | |
| 174 | ||
| 175 | ## PeerInfo API | |
| 176 | ||
| 177 | `swarm.on('connection', ...)` emits a `PeerInfo` instance whenever a new connection is established. | |
| 178 | ||
| 179 | There is a one-to-one relationship between connections and `PeerInfo` objects -- if a single peer announces multiple topics, those topics will be multiplexed over a single connection. | |
| 180 | ||
| 181 | #### `peerInfo.publicKey` | |
| 182 | ||
| 183 | The peer's Noise public key. | |
| 184 | ||
| 185 | #### `peerInfo.topics` | |
| 186 | ||
| 187 | An Array of topics that this Peer is associated with -- `topics` will only be updated when the Peer is in client mode. | |
| 188 | ||
| 189 | #### `peerInfo.prioritized` | |
| 190 | ||
| 191 | If true, the swarm will rapidly attempt to reconnect to this peer. | |
| 192 | ||
| 193 | #### `peerInfo.ban(banStatus = false)` | |
| 194 | ||
| 195 | Ban or unban the peer. Banning will prevent any future reconnection attempts, but it will **not** close any existing connections. | |
| 196 | ||
| 197 | ## License | |
| 198 | ||
| 199 | MIT |
| … | … | @@ -0,0 +1,688 @@ |
| 1 | const { EventEmitter } = require('events') | |
| 2 | const { getStreamError } = require('streamx') | |
| 3 | const DHT = require('hyperdht') | |
| 4 | const spq = require('shuffled-priority-queue') | |
| 5 | const b4a = require('b4a') | |
| 6 | const unslab = require('unslab') | |
| 7 | ||
| 8 | const PeerInfo = require('./lib/peer-info') | |
| 9 | const RetryTimer = require('./lib/retry-timer') | |
| 10 | const ConnectionSet = require('./lib/connection-set') | |
| 11 | const PeerDiscovery = require('./lib/peer-discovery') | |
| 12 | ||
| 13 | const MAX_PEERS = 64 | |
| 14 | const MAX_PARALLEL = 3 | |
| 15 | const MAX_CLIENT_CONNECTIONS = Infinity // TODO: Change | |
| 16 | const MAX_SERVER_CONNECTIONS = Infinity | |
| 17 | ||
| 18 | const ERR_MISSING_TOPIC = 'Topic is required and must be a 32-byte buffer' | |
| 19 | const ERR_DESTROYED = 'Swarm has been destroyed' | |
| 20 | const ERR_DUPLICATE = 'Duplicate connection' | |
| 21 | const ERR_FIREWALL = 'Peer is firewalled' | |
| 22 | ||
| 23 | module.exports = class Hyperswarm extends EventEmitter { | |
| 24 | constructor(opts = {}) { | |
| 25 | super() | |
| 26 | const { | |
| 27 | seed, | |
| 28 | relayThrough, | |
| 29 | keyPair = DHT.keyPair(seed), | |
| 30 | maxPeers = MAX_PEERS, | |
| 31 | maxClientConnections = MAX_CLIENT_CONNECTIONS, | |
| 32 | maxServerConnections = MAX_SERVER_CONNECTIONS, | |
| 33 | maxParallel = MAX_PARALLEL, | |
| 34 | firewall = allowAll | |
| 35 | } = opts | |
| 36 | this.keyPair = keyPair | |
| 37 | ||
| 38 | this.dht = | |
| 39 | opts.dht || | |
| 40 | new DHT({ | |
| 41 | bootstrap: opts.bootstrap, | |
| 42 | nodes: opts.nodes, | |
| 43 | port: opts.port, | |
| 44 | deferRandomPunch: opts.deferRandomPunch, | |
| 45 | randomPunchInterval: opts.randomPunchInterval | |
| 46 | }) | |
| 47 | this.server = this.dht.createServer( | |
| 48 | { | |
| 49 | firewall: this._handleFirewall.bind(this), | |
| 50 | relayThrough: this._maybeRelayConnection.bind(this), | |
| 51 | handshakeClearWait: opts.handshakeClearWait | |
| 52 | }, | |
| 53 | this._handleServerConnection.bind(this) | |
| 54 | ) | |
| 55 | ||
| 56 | this.destroyed = false | |
| 57 | this.suspended = false | |
| 58 | this.maxPeers = maxPeers | |
| 59 | this.maxClientConnections = maxClientConnections | |
| 60 | this.maxServerConnections = maxServerConnections | |
| 61 | this.maxParallel = maxParallel | |
| 62 | this.relayThrough = relayThrough ? toRelayFunction(relayThrough) : null | |
| 63 | ||
| 64 | this.connecting = 0 | |
| 65 | this.connections = new Set() | |
| 66 | this.peers = new Map() | |
| 67 | this.explicitPeers = new Set() | |
| 68 | this.listening = null | |
| 69 | this.stats = { | |
| 70 | updates: 0, | |
| 71 | connects: { | |
| 72 | client: { | |
| 73 | opened: 0, | |
| 74 | closed: 0, | |
| 75 | attempted: 0 | |
| 76 | }, | |
| 77 | server: { | |
| 78 | // Note: there is no notion of 'attempts' for server connections | |
| 79 | opened: 0, | |
| 80 | closed: 0 | |
| 81 | } | |
| 82 | }, | |
| 83 | bannedPeers: 0 | |
| 84 | } | |
| 85 | ||
| 86 | this._discovery = new Map() | |
| 87 | this._timer = new RetryTimer(this._requeue.bind(this), { | |
| 88 | backoffs: opts.backoffs, | |
| 89 | jitter: opts.jitter | |
| 90 | }) | |
| 91 | this._queue = spq() | |
| 92 | ||
| 93 | this._allConnections = new ConnectionSet() | |
| 94 | this._pendingFlushes = [] | |
| 95 | this._flushTick = 0 | |
| 96 | ||
| 97 | this._drainingQueue = false | |
| 98 | this._clientConnections = 0 | |
| 99 | this._serverConnections = 0 | |
| 100 | this._firewall = firewall | |
| 101 | ||
| 102 | this.dht.on('network-change', this._handleNetworkChange.bind(this)) | |
| 103 | this.dht.on('network-update', this._handleNetworkUpdate.bind(this)) | |
| 104 | this.on('update', this._handleUpdate) | |
| 105 | } | |
| 106 | ||
| 107 | _maybeRelayConnection(force) { | |
| 108 | if (!this.relayThrough) return null | |
| 109 | return this.relayThrough(force, this) | |
| 110 | } | |
| 111 | ||
| 112 | _enqueue(peerInfo) { | |
| 113 | if (peerInfo.queued) return | |
| 114 | peerInfo.queued = true | |
| 115 | peerInfo._flushTick = this._flushTick | |
| 116 | this._queue.add(peerInfo) | |
| 117 | ||
| 118 | this._attemptClientConnections() | |
| 119 | } | |
| 120 | ||
| 121 | _requeue(batch) { | |
| 122 | if (this.suspended) return | |
| 123 | for (const peerInfo of batch) { | |
| 124 | peerInfo.waiting = false | |
| 125 | ||
| 126 | if ( | |
| 127 | peerInfo._updatePriority() === false || | |
| 128 | this._allConnections.has(peerInfo.publicKey) || | |
| 129 | peerInfo.queued | |
| 130 | ) { | |
| 131 | continue | |
| 132 | } | |
| 133 | peerInfo.queued = true | |
| 134 | peerInfo._flushTick = this._flushTick | |
| 135 | this._queue.add(peerInfo) | |
| 136 | } | |
| 137 | ||
| 138 | this._attemptClientConnections() | |
| 139 | } | |
| 140 | ||
| 141 | _flushMaybe(peerInfo) { | |
| 142 | for (let i = 0; i < this._pendingFlushes.length; i++) { | |
| 143 | const flush = this._pendingFlushes[i] | |
| 144 | if (peerInfo._flushTick > flush.tick) continue | |
| 145 | if (--flush.missing > 0) continue | |
| 146 | flush.onflush(true) | |
| 147 | this._pendingFlushes.splice(i--, 1) | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | _withinMaxPeers() { | |
| 152 | const e = this.explicitPeers.size | |
| 153 | const factor = e === 0 ? 1 : e < 2 ? 2 : e < 4 ? 3 : 4 | |
| 154 | return factor * this._allConnections.size < this.maxPeers | |
| 155 | } | |
| 156 | ||
| 157 | _flushAllMaybe() { | |
| 158 | if ( | |
| 159 | this.connecting > 0 || | |
| 160 | (this._withinMaxPeers() && this._clientConnections < this.maxClientConnections) | |
| 161 | ) { | |
| 162 | return false | |
| 163 | } | |
| 164 | ||
| 165 | while (this._pendingFlushes.length) { | |
| 166 | const flush = this._pendingFlushes.pop() | |
| 167 | flush.onflush(true) | |
| 168 | } | |
| 169 | ||
| 170 | return true | |
| 171 | } | |
| 172 | ||
| 173 | _shouldConnectExplicit() { | |
| 174 | return !this.destroyed && !this.suspended && this.connecting < this.maxParallel | |
| 175 | } | |
| 176 | ||
| 177 | _shouldConnect() { | |
| 178 | return ( | |
| 179 | !this.destroyed && | |
| 180 | !this.suspended && | |
| 181 | this.connecting < this.maxParallel && | |
| 182 | this._withinMaxPeers() && | |
| 183 | this._clientConnections < this.maxClientConnections | |
| 184 | ) | |
| 185 | } | |
| 186 | ||
| 187 | _shouldRequeue(peerInfo) { | |
| 188 | if (this.suspended) return false | |
| 189 | if (peerInfo.explicit) return true | |
| 190 | for (const topic of peerInfo.topics) { | |
| 191 | if (this._discovery.has(b4a.toString(topic, 'hex')) && !this.destroyed) { | |
| 192 | return true | |
| 193 | } | |
| 194 | } | |
| 195 | return false | |
| 196 | } | |
| 197 | ||
| 198 | _connect(peerInfo, queued) { | |
| 199 | if (peerInfo.banned || this._allConnections.has(peerInfo.publicKey)) { | |
| 200 | if (queued) this._flushMaybe(peerInfo) | |
| 201 | return | |
| 202 | } | |
| 203 | ||
| 204 | // TODO: Support async firewalling at some point. | |
| 205 | if (this._handleFirewall(peerInfo.publicKey, null)) { | |
| 206 | if (queued) this._flushMaybe(peerInfo) | |
| 207 | return | |
| 208 | } | |
| 209 | ||
| 210 | const relayThrough = this._maybeRelayConnection(peerInfo.forceRelaying) | |
| 211 | const conn = this.dht.connect(peerInfo.publicKey, { | |
| 212 | relayAddresses: peerInfo.relayAddresses, | |
| 213 | keyPair: this.keyPair, | |
| 214 | relayThrough | |
| 215 | }) | |
| 216 | this._allConnections.add(conn) | |
| 217 | ||
| 218 | this.stats.connects.client.attempted++ | |
| 219 | ||
| 220 | this.connecting++ | |
| 221 | this._clientConnections++ | |
| 222 | let opened = false | |
| 223 | ||
| 224 | const onerror = (err) => { | |
| 225 | if (this.relayThrough && shouldForceRelaying(err.code)) { | |
| 226 | peerInfo.forceRelaying = true | |
| 227 | // Reset the attempts in order to fast connect to relay | |
| 228 | peerInfo.attempts = 0 | |
| 229 | } | |
| 230 | } | |
| 231 | ||
| 232 | // Removed once a connection is opened | |
| 233 | conn.on('error', onerror) | |
| 234 | ||
| 235 | conn.on('open', () => { | |
| 236 | opened = true | |
| 237 | this.stats.connects.client.opened++ | |
| 238 | ||
| 239 | this._connectDone() | |
| 240 | this.connections.add(conn) | |
| 241 | conn.removeListener('error', onerror) | |
| 242 | peerInfo._connected() | |
| 243 | peerInfo.client = true | |
| 244 | this.emit('connection', conn, peerInfo) | |
| 245 | if (queued) this._flushMaybe(peerInfo) | |
| 246 | ||
| 247 | this.emit('update') | |
| 248 | }) | |
| 249 | conn.on('close', () => { | |
| 250 | if (!opened) this._connectDone() | |
| 251 | this.stats.connects.client.closed++ | |
| 252 | ||
| 253 | const err = getStreamError(conn) | |
| 254 | if (shouldBan(err)) { | |
| 255 | this._banPeer(peerInfo, true, err) | |
| 256 | } | |
| 257 | ||
| 258 | this.connections.delete(conn) | |
| 259 | this._allConnections.delete(conn) | |
| 260 | this._clientConnections-- | |
| 261 | peerInfo._disconnected() | |
| 262 | ||
| 263 | peerInfo.waiting = this._shouldRequeue(peerInfo) && this._timer.add(peerInfo) | |
| 264 | this._maybeDeletePeer(peerInfo) | |
| 265 | ||
| 266 | if (!opened && queued) this._flushMaybe(peerInfo) | |
| 267 | ||
| 268 | this._attemptClientConnections() | |
| 269 | ||
| 270 | this.emit('update') | |
| 271 | }) | |
| 272 | ||
| 273 | this.emit('update') | |
| 274 | } | |
| 275 | ||
| 276 | _connectDone() { | |
| 277 | this.connecting-- | |
| 278 | ||
| 279 | if (this.connecting < this.maxParallel) this._attemptClientConnections() | |
| 280 | if (this.connecting === 0) this._flushAllMaybe() | |
| 281 | } | |
| 282 | ||
| 283 | // Called when the PeerQueue indicates a connection should be attempted. | |
| 284 | _attemptClientConnections() { | |
| 285 | // Guard against re-entries - unsure if it still needed but doesn't hurt | |
| 286 | if (this._drainingQueue || this.suspended) return | |
| 287 | this._drainingQueue = true | |
| 288 | ||
| 289 | for (const peerInfo of this.explicitPeers) { | |
| 290 | if (!this._shouldConnectExplicit()) break | |
| 291 | if ( | |
| 292 | peerInfo.attempts >= 5 || | |
| 293 | Date.now() - peerInfo.disconnectedTime < peerInfo.attempts * 1000 | |
| 294 | ) { | |
| 295 | continue | |
| 296 | } | |
| 297 | this._connect(peerInfo, false) | |
| 298 | } | |
| 299 | ||
| 300 | while (this._queue.length && this._shouldConnect()) { | |
| 301 | const peerInfo = this._queue.shift() | |
| 302 | peerInfo.queued = false | |
| 303 | this._connect(peerInfo, true) | |
| 304 | } | |
| 305 | this._drainingQueue = false | |
| 306 | if (this.connecting === 0) this._flushAllMaybe() | |
| 307 | } | |
| 308 | ||
| 309 | _handleFirewall(remotePublicKey, payload) { | |
| 310 | if (b4a.equals(remotePublicKey, this.keyPair.publicKey)) return true | |
| 311 | ||
| 312 | let peerInfo = this.peers.get(b4a.toString(remotePublicKey, 'hex')) | |
| 313 | if (peerInfo && peerInfo.banned) return true | |
| 314 | ||
| 315 | const firewalled = this._firewall(remotePublicKey, payload) | |
| 316 | if (firewalled) { | |
| 317 | if (!peerInfo) peerInfo = this._upsertPeer(remotePublicKey) | |
| 318 | this._banPeer(peerInfo, true, new Error(ERR_FIREWALL)) | |
| 319 | } | |
| 320 | ||
| 321 | return firewalled | |
| 322 | } | |
| 323 | ||
| 324 | _handleServerConnectionSwap(existing, conn) { | |
| 325 | let closed = false | |
| 326 | ||
| 327 | existing.on('close', () => { | |
| 328 | if (closed) return | |
| 329 | ||
| 330 | conn.removeListener('error', noop) | |
| 331 | conn.removeListener('close', onclose) | |
| 332 | ||
| 333 | this._handleServerConnection(conn) | |
| 334 | }) | |
| 335 | ||
| 336 | conn.on('error', noop) | |
| 337 | conn.on('close', onclose) | |
| 338 | ||
| 339 | function onclose() { | |
| 340 | closed = true | |
| 341 | } | |
| 342 | } | |
| 343 | ||
| 344 | // Called when the DHT receives a new server connection. | |
| 345 | _handleServerConnection(conn) { | |
| 346 | if (this.destroyed || this.suspended) { | |
| 347 | // TODO: Investigate why a final server connection can be received after close | |
| 348 | conn.on('error', noop) | |
| 349 | return conn.destroy(ERR_DESTROYED) | |
| 350 | } | |
| 351 | ||
| 352 | const existing = this._allConnections.get(conn.remotePublicKey) | |
| 353 | ||
| 354 | if (existing) { | |
| 355 | // If both connections are from the same peer, | |
| 356 | // - pick the new one if the existing stream is already established (has sent and received bytes), | |
| 357 | // because the other client must have lost that connection and be reconnecting | |
| 358 | // - otherwise, pick the one thats expected to initiate in a tie break | |
| 359 | const existingIsOutdated = existing.rawBytesRead > 0 && existing.rawBytesWritten > 0 | |
| 360 | const expectedInitiator = b4a.compare(conn.publicKey, conn.remotePublicKey) > 0 | |
| 361 | const keepNew = existingIsOutdated || expectedInitiator === conn.isInitiator | |
| 362 | ||
| 363 | if (keepNew === false) { | |
| 364 | existing.sendKeepAlive() | |
| 365 | conn.on('error', noop) | |
| 366 | conn.destroy(new Error(ERR_DUPLICATE)) | |
| 367 | return | |
| 368 | } | |
| 369 | ||
| 370 | existing.on('error', noop) | |
| 371 | existing.destroy(new Error(ERR_DUPLICATE)) | |
| 372 | this._handleServerConnectionSwap(existing, conn) | |
| 373 | return | |
| 374 | } | |
| 375 | ||
| 376 | // When reaching here, the connection will always be 'opened' next tick | |
| 377 | this.stats.connects.server.opened++ | |
| 378 | ||
| 379 | const peerInfo = this._upsertPeer(conn.remotePublicKey, null) | |
| 380 | ||
| 381 | this.connections.add(conn) | |
| 382 | this._allConnections.add(conn) | |
| 383 | this._serverConnections++ | |
| 384 | ||
| 385 | conn.on('close', () => { | |
| 386 | const err = getStreamError(conn) | |
| 387 | if (shouldBan(err)) { | |
| 388 | this._banPeer(peerInfo, true, err) | |
| 389 | } | |
| 390 | ||
| 391 | this.connections.delete(conn) | |
| 392 | this._allConnections.delete(conn) | |
| 393 | this._serverConnections-- | |
| 394 | this.stats.connects.server.closed++ | |
| 395 | ||
| 396 | this._maybeDeletePeer(peerInfo) | |
| 397 | ||
| 398 | this._attemptClientConnections() | |
| 399 | ||
| 400 | this.emit('update') | |
| 401 | }) | |
| 402 | peerInfo.client = false | |
| 403 | this.emit('connection', conn, peerInfo) | |
| 404 | ||
| 405 | this.emit('update') | |
| 406 | } | |
| 407 | ||
| 408 | _upsertPeer(publicKey, relayAddresses) { | |
| 409 | if (b4a.equals(publicKey, this.keyPair.publicKey)) return null | |
| 410 | const keyString = b4a.toString(publicKey, 'hex') | |
| 411 | let peerInfo = this.peers.get(keyString) | |
| 412 | ||
| 413 | if (peerInfo) { | |
| 414 | peerInfo.relayAddresses = relayAddresses // new is always better | |
| 415 | return peerInfo | |
| 416 | } | |
| 417 | ||
| 418 | peerInfo = new PeerInfo({ | |
| 419 | publicKey, | |
| 420 | relayAddresses | |
| 421 | }) | |
| 422 | ||
| 423 | this.peers.set(keyString, peerInfo) | |
| 424 | return peerInfo | |
| 425 | } | |
| 426 | ||
| 427 | _handleUpdate() { | |
| 428 | this.stats.updates++ | |
| 429 | } | |
| 430 | ||
| 431 | _maybeDeletePeer(peerInfo) { | |
| 432 | if (!peerInfo.shouldGC()) return | |
| 433 | ||
| 434 | const hasActiveConn = this._allConnections.has(peerInfo.publicKey) | |
| 435 | if (hasActiveConn) return | |
| 436 | ||
| 437 | const keyString = b4a.toString(peerInfo.publicKey, 'hex') | |
| 438 | this.peers.delete(keyString) | |
| 439 | } | |
| 440 | ||
| 441 | /* | |
| 442 | * Called when a peer is actively discovered during a lookup. | |
| 443 | * | |
| 444 | * Three conditions: | |
| 445 | * 1. Not a known peer -- insert into queue | |
| 446 | * 2. A known peer with normal priority -- do nothing | |
| 447 | * 3. A known peer with low priority -- bump priority, because it's been rediscovered | |
| 448 | */ | |
| 449 | _handlePeer(peer, topic) { | |
| 450 | const peerInfo = this._upsertPeer(peer.publicKey, peer.relayAddresses) | |
| 451 | if (peerInfo) peerInfo._topic(topic) | |
| 452 | if (!peerInfo || this._allConnections.has(peer.publicKey)) return | |
| 453 | if (!peerInfo.prioritized || peerInfo.server) peerInfo._reset() | |
| 454 | if (peerInfo._updatePriority()) { | |
| 455 | this._enqueue(peerInfo) | |
| 456 | } | |
| 457 | } | |
| 458 | ||
| 459 | async _handleNetworkUpdate() { | |
| 460 | if (!this.dht.online) return | |
| 461 | this._handleNetworkChange() | |
| 462 | } | |
| 463 | ||
| 464 | async _handleNetworkChange() { | |
| 465 | if (this.suspended) return | |
| 466 | ||
| 467 | // prioritize figuring out if existing connections are dead | |
| 468 | for (const conn of this._allConnections) { | |
| 469 | conn.sendKeepAlive() | |
| 470 | } | |
| 471 | ||
| 472 | const refreshes = [] | |
| 473 | ||
| 474 | for (const discovery of this._discovery.values()) { | |
| 475 | refreshes.push(discovery.refresh()) | |
| 476 | } | |
| 477 | ||
| 478 | await Promise.allSettled(refreshes) | |
| 479 | } | |
| 480 | ||
| 481 | _banPeer(peerInfo, banned, err) { | |
| 482 | peerInfo.ban(banned) | |
| 483 | this.stats.bannedPeers++ | |
| 484 | this.emit('ban', peerInfo, err) | |
| 485 | } | |
| 486 | ||
| 487 | status(key) { | |
| 488 | return this._discovery.get(b4a.toString(key, 'hex')) || null | |
| 489 | } | |
| 490 | ||
| 491 | listen() { | |
| 492 | if (!this.listening) { | |
| 493 | if (this.destroyed) throw new Error('Swarm destroyed') | |
| 494 | this.listening = this.server.listen(this.keyPair) | |
| 495 | } | |
| 496 | return this.listening | |
| 497 | } | |
| 498 | ||
| 499 | // Object that exposes a cancellation method (destroy) | |
| 500 | // TODO: When you rejoin, it should reannounce + bump lookup priority | |
| 501 | join(topic, opts = {}) { | |
| 502 | if (this.destroyed) throw new Error('Swarm destroyed') | |
| 503 | if (!topic) throw new Error(ERR_MISSING_TOPIC) | |
| 504 | topic = unslab(topic) | |
| 505 | ||
| 506 | const topicString = b4a.toString(topic, 'hex') | |
| 507 | ||
| 508 | let discovery = this._discovery.get(topicString) | |
| 509 | ||
| 510 | if (discovery && !discovery.destroyed) { | |
| 511 | return discovery.session(opts) | |
| 512 | } | |
| 513 | ||
| 514 | discovery = new PeerDiscovery(this, topic, { | |
| 515 | limit: opts.limit, | |
| 516 | wait: discovery ? discovery.destroy() : null, | |
| 517 | suspended: this.suspended, | |
| 518 | onpeer: (peer) => this._handlePeer(peer, topic) | |
| 519 | }) | |
| 520 | this._discovery.set(topicString, discovery) | |
| 521 | return discovery.session(opts) | |
| 522 | } | |
| 523 | ||
| 524 | // Returns a promise | |
| 525 | async leave(topic) { | |
| 526 | if (!topic) throw new Error(ERR_MISSING_TOPIC) | |
| 527 | const topicString = b4a.toString(topic, 'hex') | |
| 528 | if (!this._discovery.has(topicString)) return Promise.resolve() | |
| 529 | ||
| 530 | const discovery = this._discovery.get(topicString) | |
| 531 | ||
| 532 | try { | |
| 533 | await discovery.destroy() | |
| 534 | } catch { | |
| 535 | // ignore, prop network | |
| 536 | } | |
| 537 | ||
| 538 | if (this._discovery.get(topicString) === discovery) { | |
| 539 | this._discovery.delete(topicString) | |
| 540 | } | |
| 541 | } | |
| 542 | ||
| 543 | joinPeer(publicKey) { | |
| 544 | const peerInfo = this._upsertPeer(publicKey, null) | |
| 545 | if (!peerInfo) return | |
| 546 | if (!this.explicitPeers.has(peerInfo)) { | |
| 547 | peerInfo.explicit = true | |
| 548 | this.explicitPeers.add(peerInfo) | |
| 549 | } | |
| 550 | if (this._allConnections.has(publicKey)) return | |
| 551 | if (peerInfo._updatePriority()) { | |
| 552 | this._enqueue(peerInfo) | |
| 553 | } | |
| 554 | } | |
| 555 | ||
| 556 | leavePeer(publicKey) { | |
| 557 | const keyString = b4a.toString(publicKey, 'hex') | |
| 558 | if (!this.peers.has(keyString)) return | |
| 559 | ||
| 560 | const peerInfo = this.peers.get(keyString) | |
| 561 | peerInfo.explicit = false | |
| 562 | this.explicitPeers.delete(peerInfo) | |
| 563 | this._maybeDeletePeer(peerInfo) | |
| 564 | } | |
| 565 | ||
| 566 | // Returns a promise | |
| 567 | async flush() { | |
| 568 | const allFlushed = [...this._discovery.values()].map((v) => v.flushed()) | |
| 569 | await Promise.all(allFlushed) | |
| 570 | if (this._flushAllMaybe()) return true | |
| 571 | const pendingSize = this._allConnections.size - this.connections.size | |
| 572 | if (!this._queue.length && !pendingSize) return true | |
| 573 | return new Promise((resolve) => { | |
| 574 | this._pendingFlushes.push({ | |
| 575 | onflush: resolve, | |
| 576 | missing: this._queue.length + pendingSize, | |
| 577 | tick: this._flushTick++ | |
| 578 | }) | |
| 579 | }) | |
| 580 | } | |
| 581 | ||
| 582 | async clear() { | |
| 583 | const cleared = Promise.allSettled([...this._discovery.values()].map((d) => d.destroy())) | |
| 584 | this._discovery.clear() | |
| 585 | return cleared | |
| 586 | } | |
| 587 | ||
| 588 | async destroy({ force } = {}) { | |
| 589 | if (this.destroyed && !force) return | |
| 590 | this.destroyed = true | |
| 591 | ||
| 592 | this._timer.destroy() | |
| 593 | ||
| 594 | if (!force) await this.clear() | |
| 595 | ||
| 596 | await this.server.close() | |
| 597 | ||
| 598 | while (this._pendingFlushes.length) { | |
| 599 | const flush = this._pendingFlushes.pop() | |
| 600 | flush.onflush(false) | |
| 601 | } | |
| 602 | ||
| 603 | await this.dht.destroy({ force }) | |
| 604 | } | |
| 605 | ||
| 606 | async suspend({ log = noop } = {}) { | |
| 607 | if (this.suspended) return | |
| 608 | ||
| 609 | const promises = [] | |
| 610 | ||
| 611 | promises.push(this.server.suspend({ log })) | |
| 612 | ||
| 613 | for (const discovery of this._discovery.values()) { | |
| 614 | promises.push(discovery.suspend({ log })) | |
| 615 | } | |
| 616 | ||
| 617 | const pending = [] | |
| 618 | for (const connection of this._allConnections) { | |
| 619 | connection.destroy() | |
| 620 | pending.push(new Promise((resolve) => connection.on('close', resolve))) | |
| 621 | } | |
| 622 | ||
| 623 | this.suspended = true | |
| 624 | ||
| 625 | log('Suspending server and discovery... (' + promises.length + ')') | |
| 626 | await Promise.allSettled(promises) | |
| 627 | log('Done, suspending the dht...') | |
| 628 | await this.dht.suspend({ log }) | |
| 629 | log('Done, swarm fully suspended') | |
| 630 | ||
| 631 | await Promise.all(pending) | |
| 632 | ||
| 633 | // reset queue | |
| 634 | this._timer.destroy() | |
| 635 | this._timer = new RetryTimer(this._requeue.bind(this), { | |
| 636 | backoffs: this._timer.backoffs, | |
| 637 | jitter: this._timer.jitter | |
| 638 | }) | |
| 639 | this._queue = spq() | |
| 640 | } | |
| 641 | ||
| 642 | async resume({ log = noop } = {}) { | |
| 643 | if (!this.suspended) return | |
| 644 | ||
| 645 | log('Resuming the dht') | |
| 646 | await this.dht.resume() | |
| 647 | log('Done, resuming the server') | |
| 648 | await this.server.resume() | |
| 649 | log('Done, all discovery') | |
| 650 | ||
| 651 | for (const discovery of this._discovery.values()) { | |
| 652 | discovery.resume() | |
| 653 | } | |
| 654 | ||
| 655 | this.suspended = false | |
| 656 | this._attemptClientConnections() | |
| 657 | } | |
| 658 | ||
| 659 | topics() { | |
| 660 | return this._discovery.values() | |
| 661 | } | |
| 662 | } | |
| 663 | ||
| 664 | function noop() {} | |
| 665 | ||
| 666 | function allowAll() { | |
| 667 | return false | |
| 668 | } | |
| 669 | ||
| 670 | function shouldForceRelaying(code) { | |
| 671 | return ( | |
| 672 | code === 'HOLEPUNCH_ABORTED' || | |
| 673 | code === 'HOLEPUNCH_DOUBLE_RANDOMIZED_NATS' || | |
| 674 | code === 'CANNOT_HOLEPUNCH' || | |
| 675 | code === 'REMOTE_NOT_HOLEPUNCHABLE' | |
| 676 | ) | |
| 677 | } | |
| 678 | ||
| 679 | function shouldBan() { | |
| 680 | // return !!err && err.name === 'HypercoreError' && err.code === 'INVALID_OPERATION' | |
| 681 | return false | |
| 682 | } | |
| 683 | ||
| 684 | function toRelayFunction(relayThrough) { | |
| 685 | return typeof relayThrough === 'function' | |
| 686 | ? relayThrough | |
| 687 | : (force, swarm) => (force || swarm.dht.randomized ? relayThrough : null) | |
| 688 | } |
| … | … | @@ -0,0 +1,33 @@ |
| 1 | module.exports = class BulkTimer { | |
| 2 | constructor(time, fn) { | |
| 3 | this._time = time | |
| 4 | this._fn = fn | |
| 5 | this._interval = null | |
| 6 | this._next = [] | |
| 7 | this._pending = [] | |
| 8 | this._destroyed = false | |
| 9 | } | |
| 10 | ||
| 11 | destroy() { | |
| 12 | if (this._destroyed) return | |
| 13 | this._destroyed = true | |
| 14 | clearInterval(this._interval) | |
| 15 | this._interval = null | |
| 16 | } | |
| 17 | ||
| 18 | _ontick() { | |
| 19 | if (!this._next.length && !this._pending.length) return | |
| 20 | if (this._next.length) this._fn(this._next) | |
| 21 | this._next = this._pending | |
| 22 | this._pending = [] | |
| 23 | } | |
| 24 | ||
| 25 | add(info) { | |
| 26 | if (this._destroyed) return | |
| 27 | if (!this._interval) { | |
| 28 | this._interval = setInterval(this._ontick.bind(this), Math.floor(this._time * 0.66)) | |
| 29 | } | |
| 30 | ||
| 31 | this._pending.push(info) | |
| 32 | } | |
| 33 | } |
| … | … | @@ -0,0 +1,38 @@ |
| 1 | const b4a = require('b4a') | |
| 2 | ||
| 3 | module.exports = class ConnectionSet { | |
| 4 | constructor() { | |
| 5 | this._byPublicKey = new Map() | |
| 6 | } | |
| 7 | ||
| 8 | [Symbol.iterator]() { | |
| 9 | return this._byPublicKey.values() | |
| 10 | } | |
| 11 | ||
| 12 | get size() { | |
| 13 | return this._byPublicKey.size | |
| 14 | } | |
| 15 | ||
| 16 | has(publicKey) { | |
| 17 | return this._byPublicKey.has(toHex(publicKey)) | |
| 18 | } | |
| 19 | ||
| 20 | get(publicKey) { | |
| 21 | return this._byPublicKey.get(toHex(publicKey)) | |
| 22 | } | |
| 23 | ||
| 24 | add(connection) { | |
| 25 | this._byPublicKey.set(b4a.toString(connection.remotePublicKey, 'hex'), connection) | |
| 26 | } | |
| 27 | ||
| 28 | delete(connection) { | |
| 29 | const keyString = b4a.toString(connection.remotePublicKey, 'hex') | |
| 30 | const existing = this._byPublicKey.get(keyString) | |
| 31 | if (existing !== connection) return | |
| 32 | this._byPublicKey.delete(keyString) | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | function toHex(b) { | |
| 37 | return typeof b === 'string' ? b : b4a.toString(b, 'hex') | |
| 38 | } |
| … | … | @@ -0,0 +1,358 @@ |
| 1 | const safetyCatch = require('safety-catch') | |
| 2 | const b4a = require('b4a') | |
| 3 | ||
| 4 | const REFRESH_INTERVAL = 1000 * 60 * 10 // 10 min | |
| 5 | const RANDOM_JITTER = 1000 * 60 * 2 // 2 min | |
| 6 | const DELAY_GRACE_PERIOD = 1000 * 30 // 30s | |
| 7 | ||
| 8 | const MAX_DISCOVERY_CACHE = 64 | |
| 9 | ||
| 10 | module.exports = class PeerDiscovery { | |
| 11 | constructor( | |
| 12 | swarm, | |
| 13 | topic, | |
| 14 | { limit = Infinity, wait = null, suspended = false, onpeer = noop, onerror = safetyCatch } | |
| 15 | ) { | |
| 16 | this.limit = limit | |
| 17 | this.swarm = swarm | |
| 18 | this.topic = topic | |
| 19 | this.isClient = false | |
| 20 | this.isServer = false | |
| 21 | this.destroyed = false | |
| 22 | this.destroying = null | |
| 23 | this.suspended = suspended | |
| 24 | ||
| 25 | this._sessions = [] | |
| 26 | this._clientSessions = 0 | |
| 27 | this._serverSessions = 0 | |
| 28 | ||
| 29 | this._onpeer = onpeer | |
| 30 | this._onerror = onerror | |
| 31 | ||
| 32 | this._discovered = new Set() | |
| 33 | this._activeQuery = null | |
| 34 | this._timer = null | |
| 35 | this._currentRefresh = null | |
| 36 | this._closestNodes = null | |
| 37 | this._firstAnnounce = true | |
| 38 | this._needsUnannounce = false | |
| 39 | this._refreshes = 0 | |
| 40 | this._wait = wait | |
| 41 | } | |
| 42 | ||
| 43 | session({ server = true, client = true, limit = Infinity, onerror = safetyCatch }) { | |
| 44 | if (this.destroyed) throw new Error('PeerDiscovery is destroyed') | |
| 45 | const session = new PeerDiscoverySession(this) | |
| 46 | session.refresh({ server, client, limit }).catch(onerror) | |
| 47 | this._sessions.push(session) | |
| 48 | return session | |
| 49 | } | |
| 50 | ||
| 51 | _refreshLater(eager) { | |
| 52 | const jitter = Math.round(Math.random() * RANDOM_JITTER) | |
| 53 | const delay = !eager ? REFRESH_INTERVAL + jitter : jitter | |
| 54 | ||
| 55 | if (this._timer) clearTimeout(this._timer) | |
| 56 | ||
| 57 | const startTime = Date.now() | |
| 58 | this._timer = setTimeout(() => { | |
| 59 | // If your laptop went to sleep, and is coming back online... | |
| 60 | const overdue = Date.now() - startTime > delay + DELAY_GRACE_PERIOD | |
| 61 | if (overdue) this._refreshLater(true) | |
| 62 | else this.refresh().catch(this._onerror) | |
| 63 | }, delay) | |
| 64 | } | |
| 65 | ||
| 66 | _isActive() { | |
| 67 | return !this.destroyed && !this.suspended | |
| 68 | } | |
| 69 | ||
| 70 | // TODO: Allow announce to be an argument to this | |
| 71 | // TODO: Maybe announce should be a setter? | |
| 72 | async _refresh() { | |
| 73 | if (this.suspended) return | |
| 74 | const clock = ++this._refreshes | |
| 75 | ||
| 76 | if (this._wait) { | |
| 77 | await this._wait | |
| 78 | this._wait = null | |
| 79 | if (clock !== this._refreshes || !this._isActive()) return | |
| 80 | } | |
| 81 | ||
| 82 | const clear = this.isServer && this._firstAnnounce | |
| 83 | if (clear) this._firstAnnounce = false | |
| 84 | ||
| 85 | const opts = { | |
| 86 | clear, | |
| 87 | closestNodes: this._closestNodes | |
| 88 | } | |
| 89 | ||
| 90 | if (this.isServer) { | |
| 91 | await this.swarm.listen() | |
| 92 | // if a parallel refresh is happening, yield to the new one | |
| 93 | if (clock !== this._refreshes || !this._isActive()) return | |
| 94 | this._needsUnannounce = true | |
| 95 | } | |
| 96 | ||
| 97 | let limit = this.limit | |
| 98 | ||
| 99 | if (limit < Infinity && limit > 0) { | |
| 100 | for (const id of this._discovered) { | |
| 101 | if (!this.swarm.connections.has(id)) continue | |
| 102 | if (--limit === 0) break | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | this._discovered.clear() | |
| 107 | ||
| 108 | const announcing = this.isServer | |
| 109 | const query = (this._activeQuery = announcing | |
| 110 | ? this.swarm.dht.announce( | |
| 111 | this.topic, | |
| 112 | this.swarm.keyPair, | |
| 113 | this.swarm.server.relayAddresses, | |
| 114 | opts | |
| 115 | ) | |
| 116 | : this._needsUnannounce | |
| 117 | ? this.swarm.dht.lookupAndUnannounce(this.topic, this.swarm.keyPair, opts) | |
| 118 | : this.swarm.dht.lookup(this.topic, opts)) | |
| 119 | ||
| 120 | try { | |
| 121 | for await (const data of this._activeQuery) { | |
| 122 | if (!this.isClient || !this._isActive()) continue | |
| 123 | for (const peer of data.peers) { | |
| 124 | if (limit < Infinity) { | |
| 125 | const id = b4a.toString(peer.publicKey, 'hex') | |
| 126 | ||
| 127 | if (this._discovered.size < MAX_DISCOVERY_CACHE) { | |
| 128 | this._discovered.add(id) | |
| 129 | } | |
| 130 | ||
| 131 | if (limit === 0) continue | |
| 132 | ||
| 133 | // there is a chance it has updated a connection during discovery and we go over | |
| 134 | // the limit - thats acceptable to avoid a complexity spiral here. | |
| 135 | if (!this.swarm.connections.has(id)) limit-- | |
| 136 | } | |
| 137 | ||
| 138 | this._onpeer(peer, data) | |
| 139 | } | |
| 140 | } | |
| 141 | } catch (err) { | |
| 142 | if (this._isActive()) throw err | |
| 143 | } finally { | |
| 144 | if (this._activeQuery === query) { | |
| 145 | this._activeQuery = null | |
| 146 | if (!this.destroyed && !this.suspended) this._refreshLater(false) | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | // This is set at the very end, when the query completes successfully. | |
| 151 | this._closestNodes = query.closestNodes | |
| 152 | ||
| 153 | if (clock !== this._refreshes) return | |
| 154 | ||
| 155 | // In this is the latest query, unannounce has been fulfilled as well | |
| 156 | if (!announcing) this._needsUnannounce = false | |
| 157 | } | |
| 158 | ||
| 159 | async refresh() { | |
| 160 | if (this.destroyed) throw new Error('PeerDiscovery is destroyed') | |
| 161 | ||
| 162 | const server = this._serverSessions > 0 | |
| 163 | const client = this._clientSessions > 0 | |
| 164 | ||
| 165 | if (this.suspended) return | |
| 166 | ||
| 167 | if (server === this.isServer && client === this.isClient) { | |
| 168 | if (this._currentRefresh) return this._currentRefresh | |
| 169 | this._currentRefresh = this._refresh() | |
| 170 | } else { | |
| 171 | if (this._activeQuery) this._activeQuery.destroy() | |
| 172 | this.isServer = server | |
| 173 | this.isClient = client | |
| 174 | this._currentRefresh = this._refresh() | |
| 175 | } | |
| 176 | ||
| 177 | const refresh = this._currentRefresh | |
| 178 | try { | |
| 179 | await refresh | |
| 180 | } catch { | |
| 181 | return false | |
| 182 | } finally { | |
| 183 | if (refresh === this._currentRefresh) { | |
| 184 | this._currentRefresh = null | |
| 185 | } | |
| 186 | } | |
| 187 | ||
| 188 | return true | |
| 189 | } | |
| 190 | ||
| 191 | async flushed() { | |
| 192 | if (this.swarm.listening) await this.swarm.listening | |
| 193 | ||
| 194 | try { | |
| 195 | await this._currentRefresh | |
| 196 | return true | |
| 197 | } catch { | |
| 198 | return false | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | async _destroyMaybe() { | |
| 203 | if (this.destroyed) return | |
| 204 | ||
| 205 | try { | |
| 206 | if (this._sessions.length === 0) await this.swarm.leave(this.topic) | |
| 207 | else if (this._serverSessions === 0 && this._needsUnannounce) await this.refresh() | |
| 208 | } catch (err) { | |
| 209 | // ignore network failures here, as we are tearing down | |
| 210 | safetyCatch(err) | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 214 | destroy() { | |
| 215 | if (this.destroying) return this.destroying | |
| 216 | this.destroying = this._destroy() | |
| 217 | return this.destroying | |
| 218 | } | |
| 219 | ||
| 220 | async _abort(log) { | |
| 221 | const id = log === noop ? '' : b4a.toString(this.topic, 'hex') | |
| 222 | ||
| 223 | log('Aborting discovery', id) | |
| 224 | if (this._wait) await this._wait | |
| 225 | log('Aborting discovery (post wait)', id) | |
| 226 | ||
| 227 | if (this._activeQuery) { | |
| 228 | this._activeQuery.destroy() | |
| 229 | this._activeQuery = null | |
| 230 | } | |
| 231 | if (this._timer) { | |
| 232 | clearTimeout(this._timer) | |
| 233 | this._timer = null | |
| 234 | } | |
| 235 | ||
| 236 | let nodes = this._closestNodes | |
| 237 | ||
| 238 | if (this._currentRefresh) { | |
| 239 | try { | |
| 240 | await this._currentRefresh | |
| 241 | } catch { | |
| 242 | // If the destroy causes the refresh to fail, suppress it. | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | log('Aborting discovery (post refresh)', id) | |
| 247 | if (this._isActive()) return | |
| 248 | ||
| 249 | if (!nodes) nodes = this._closestNodes | |
| 250 | else if (this._closestNodes !== nodes) { | |
| 251 | const len = nodes.length | |
| 252 | for (const newer of this._closestNodes) { | |
| 253 | if (newer.id && !hasNode(nodes, len, newer)) nodes.push(newer) | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | if (this._needsUnannounce) { | |
| 258 | log('Unannouncing discovery', id) | |
| 259 | if (nodes && nodes.length) { | |
| 260 | await this.swarm.dht.unannounce(this.topic, this.swarm.keyPair, { | |
| 261 | closestNodes: nodes, | |
| 262 | onlyClosestNodes: true, | |
| 263 | force: true | |
| 264 | }) | |
| 265 | } | |
| 266 | this._needsUnannounce = false | |
| 267 | log('Unannouncing discovery (done)', id) | |
| 268 | } | |
| 269 | } | |
| 270 | ||
| 271 | _destroy() { | |
| 272 | if (this.destroyed) return | |
| 273 | this.destroyed = true | |
| 274 | return this._abort(noop) | |
| 275 | } | |
| 276 | ||
| 277 | async suspend({ log = noop } = {}) { | |
| 278 | if (this.suspended) return | |
| 279 | this.suspended = true | |
| 280 | try { | |
| 281 | await this._abort(log) | |
| 282 | } catch { | |
| 283 | // ignore | |
| 284 | } | |
| 285 | } | |
| 286 | ||
| 287 | resume() { | |
| 288 | if (!this.suspended) return | |
| 289 | this.suspended = false | |
| 290 | this.refresh().catch(noop) | |
| 291 | } | |
| 292 | } | |
| 293 | ||
| 294 | class PeerDiscoverySession { | |
| 295 | constructor(discovery) { | |
| 296 | this.discovery = discovery | |
| 297 | this.isClient = false | |
| 298 | this.isServer = false | |
| 299 | this.destroyed = false | |
| 300 | } | |
| 301 | ||
| 302 | get swarm() { | |
| 303 | return this.discovery.swarm | |
| 304 | } | |
| 305 | ||
| 306 | get topic() { | |
| 307 | return this.discovery.topic | |
| 308 | } | |
| 309 | ||
| 310 | async refresh({ client = this.isClient, server = this.isServer, limit = Infinity } = {}) { | |
| 311 | if (this.destroyed) throw new Error('PeerDiscovery is destroyed') | |
| 312 | if (!client && !server) throw new Error('Cannot refresh with neither client nor server option') | |
| 313 | ||
| 314 | if (client !== this.isClient) { | |
| 315 | this.isClient = client | |
| 316 | this.discovery._clientSessions += client ? 1 : -1 | |
| 317 | } | |
| 318 | ||
| 319 | if (server !== this.isServer) { | |
| 320 | this.isServer = server | |
| 321 | this.discovery._serverSessions += server ? 1 : -1 | |
| 322 | } | |
| 323 | ||
| 324 | this.discovery.limit = limit | |
| 325 | ||
| 326 | return this.discovery.refresh() | |
| 327 | } | |
| 328 | ||
| 329 | async flushed() { | |
| 330 | return this.discovery.flushed() | |
| 331 | } | |
| 332 | ||
| 333 | async destroy() { | |
| 334 | if (this.destroyed) return | |
| 335 | this.destroyed = true | |
| 336 | ||
| 337 | if (this.isClient) this.discovery._clientSessions-- | |
| 338 | if (this.isServer) this.discovery._serverSessions-- | |
| 339 | ||
| 340 | const index = this.discovery._sessions.indexOf(this) | |
| 341 | const head = this.discovery._sessions.pop() | |
| 342 | ||
| 343 | if (head !== this) this.discovery._sessions[index] = head | |
| 344 | ||
| 345 | return this.discovery._destroyMaybe() | |
| 346 | } | |
| 347 | } | |
| 348 | ||
| 349 | function hasNode(nodes, len, node) { | |
| 350 | for (let i = 0; i < len; i++) { | |
| 351 | const existing = nodes[i] | |
| 352 | if (existing.id && b4a.equals(existing.id, node.id)) return true | |
| 353 | } | |
| 354 | ||
| 355 | return false | |
| 356 | } | |
| 357 | ||
| 358 | function noop() {} |
| … | … | @@ -0,0 +1,116 @@ |
| 1 | const { EventEmitter } = require('events') | |
| 2 | const b4a = require('b4a') | |
| 3 | const unslab = require('unslab') | |
| 4 | ||
| 5 | const MIN_CONNECTION_TIME = 15000 | |
| 6 | ||
| 7 | const VERY_LOW_PRIORITY = 0 | |
| 8 | const LOW_PRIORITY = 1 | |
| 9 | const NORMAL_PRIORITY = 2 | |
| 10 | const HIGH_PRIORITY = 3 | |
| 11 | const VERY_HIGH_PRIORITY = 4 | |
| 12 | ||
| 13 | module.exports = class PeerInfo extends EventEmitter { | |
| 14 | constructor({ publicKey, relayAddresses }) { | |
| 15 | super() | |
| 16 | ||
| 17 | this.publicKey = unslab(publicKey) | |
| 18 | this.relayAddresses = relayAddresses | |
| 19 | ||
| 20 | this.reconnecting = true | |
| 21 | this.proven = false | |
| 22 | this.connectedTime = -1 | |
| 23 | this.disconnectedTime = 0 | |
| 24 | this.banned = false | |
| 25 | this.tried = false | |
| 26 | this.explicit = false | |
| 27 | this.waiting = false | |
| 28 | this.forceRelaying = false | |
| 29 | ||
| 30 | // Set by the Swarm | |
| 31 | this.queued = false | |
| 32 | this.client = false | |
| 33 | this.topics = [] // TODO: remove on next major (check with mafintosh for context) | |
| 34 | ||
| 35 | this.attempts = 0 | |
| 36 | this.priority = NORMAL_PRIORITY | |
| 37 | ||
| 38 | // Used by shuffled-priority-queue | |
| 39 | this._index = 0 | |
| 40 | ||
| 41 | // Used for flush management | |
| 42 | this._flushTick = 0 | |
| 43 | ||
| 44 | // Used for topic multiplexing | |
| 45 | this._seenTopics = new Set() | |
| 46 | } | |
| 47 | ||
| 48 | get server() { | |
| 49 | return !this.client | |
| 50 | } | |
| 51 | ||
| 52 | get prioritized() { | |
| 53 | return this.priority >= NORMAL_PRIORITY | |
| 54 | } | |
| 55 | ||
| 56 | _getPriority() { | |
| 57 | const peerIsStale = this.tried && !this.proven | |
| 58 | if (peerIsStale || this.attempts > 3) return VERY_LOW_PRIORITY | |
| 59 | if (this.attempts === 3) return LOW_PRIORITY | |
| 60 | if (this.attempts === 2) return HIGH_PRIORITY | |
| 61 | if (this.attempts === 1) return VERY_HIGH_PRIORITY | |
| 62 | return NORMAL_PRIORITY | |
| 63 | } | |
| 64 | ||
| 65 | _connected() { | |
| 66 | this.proven = true | |
| 67 | this.connectedTime = Date.now() | |
| 68 | } | |
| 69 | ||
| 70 | _disconnected() { | |
| 71 | this.disconnectedTime = Date.now() | |
| 72 | if (this.connectedTime > -1) { | |
| 73 | if (this.disconnectedTime - this.connectedTime >= MIN_CONNECTION_TIME) this.attempts = 0 // fast retry | |
| 74 | this.connectedTime = -1 | |
| 75 | } | |
| 76 | this.attempts++ | |
| 77 | } | |
| 78 | ||
| 79 | _deprioritize() { | |
| 80 | this.attempts = 3 | |
| 81 | } | |
| 82 | ||
| 83 | _reset() { | |
| 84 | this.client = false | |
| 85 | this.proven = false | |
| 86 | this.tried = false | |
| 87 | this.attempts = 0 | |
| 88 | } | |
| 89 | ||
| 90 | _updatePriority() { | |
| 91 | if (this.explicit && this.attempts > 3) this._deprioritize() | |
| 92 | if (this.banned || this.queued || this.attempts > 3) return false | |
| 93 | this.priority = this._getPriority() | |
| 94 | return true | |
| 95 | } | |
| 96 | ||
| 97 | _topic(topic) { | |
| 98 | const topicString = b4a.toString(topic, 'hex') | |
| 99 | if (this._seenTopics.has(topicString)) return | |
| 100 | this._seenTopics.add(topicString) | |
| 101 | this.topics.push(topic) | |
| 102 | this.emit('topic', topic) | |
| 103 | } | |
| 104 | ||
| 105 | reconnect(val) { | |
| 106 | this.reconnecting = !!val | |
| 107 | } | |
| 108 | ||
| 109 | ban(val) { | |
| 110 | this.banned = !!val | |
| 111 | } | |
| 112 | ||
| 113 | shouldGC() { | |
| 114 | return !(this.banned || this.queued || this.explicit || this.waiting) | |
| 115 | } | |
| 116 | } |
| … | … | @@ -0,0 +1,68 @@ |
| 1 | const BulkTimer = require('./bulk-timer') | |
| 2 | ||
| 3 | const BACKOFF_JITTER = 500 | |
| 4 | const BACKOFF_S = 1000 + Math.round(BACKOFF_JITTER * Math.random()) | |
| 5 | const BACKOFF_M = 5000 + Math.round(2 * BACKOFF_JITTER * Math.random()) | |
| 6 | const BACKOFF_L = 15000 + Math.round(4 * BACKOFF_JITTER * Math.random()) | |
| 7 | const BACKOFF_X = 1000 * 60 * 10 + Math.round(240 * BACKOFF_JITTER * Math.random()) | |
| 8 | ||
| 9 | module.exports = class RetryTimer { | |
| 10 | constructor( | |
| 11 | push, | |
| 12 | { backoffs = [BACKOFF_S, BACKOFF_M, BACKOFF_L, BACKOFF_X], jitter = BACKOFF_JITTER } = {} | |
| 13 | ) { | |
| 14 | this.jitter = jitter | |
| 15 | this.backoffs = backoffs | |
| 16 | ||
| 17 | this._sTimer = new BulkTimer(backoffs[0] + Math.round(jitter * Math.random()), push) | |
| 18 | this._mTimer = new BulkTimer(backoffs[1] + Math.round(jitter * Math.random()), push) | |
| 19 | this._lTimer = new BulkTimer(backoffs[2] + Math.round(jitter * Math.random()), push) | |
| 20 | this._xTimer = new BulkTimer(backoffs[3] + Math.round(jitter * Math.random()), push) | |
| 21 | } | |
| 22 | ||
| 23 | _selectRetryTimer(peerInfo) { | |
| 24 | if (peerInfo.banned || !peerInfo.reconnecting) return null | |
| 25 | ||
| 26 | if (peerInfo.attempts > 3) { | |
| 27 | return peerInfo.explicit ? this._xTimer : null | |
| 28 | } | |
| 29 | ||
| 30 | if (peerInfo.attempts === 0) return this._sTimer | |
| 31 | if (peerInfo.proven) { | |
| 32 | switch (peerInfo.attempts) { | |
| 33 | case 1: | |
| 34 | return this._sTimer | |
| 35 | case 2: | |
| 36 | return this._mTimer | |
| 37 | case 3: | |
| 38 | return this._lTimer | |
| 39 | } | |
| 40 | } else { | |
| 41 | switch (peerInfo.attempts) { | |
| 42 | case 1: | |
| 43 | return this._mTimer | |
| 44 | case 2: | |
| 45 | return this._lTimer | |
| 46 | case 3: | |
| 47 | return this._lTimer | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | return null | |
| 52 | } | |
| 53 | ||
| 54 | add(peerInfo) { | |
| 55 | const timer = this._selectRetryTimer(peerInfo) | |
| 56 | if (!timer) return false | |
| 57 | ||
| 58 | timer.add(peerInfo) | |
| 59 | return true | |
| 60 | } | |
| 61 | ||
| 62 | destroy() { | |
| 63 | this._sTimer.destroy() | |
| 64 | this._mTimer.destroy() | |
| 65 | this._lTimer.destroy() | |
| 66 | this._xTimer.destroy() | |
| 67 | } | |
| 68 | } |
| … | … | @@ -0,0 +1,59 @@ |
| 1 | { | |
| 2 | "name": "hyperswarm", | |
| 3 | "version": "4.17.1", | |
| 4 | "description": "A distributed networking stack for connecting peers", | |
| 5 | "files": [ | |
| 6 | "index.js", | |
| 7 | "lib/**.js" | |
| 8 | ], | |
| 9 | "imports": { | |
| 10 | "events": { | |
| 11 | "bare": "bare-events", | |
| 12 | "default": "events" | |
| 13 | }, | |
| 14 | "process": { | |
| 15 | "bare": "bare-process", | |
| 16 | "default": "process" | |
| 17 | } | |
| 18 | }, | |
| 19 | "dependencies": { | |
| 20 | "b4a": "^1.3.1", | |
| 21 | "bare-events": "^2.2.0", | |
| 22 | "hyperdht": "^6.21.0", | |
| 23 | "safety-catch": "^1.0.2", | |
| 24 | "shuffled-priority-queue": "^2.1.0", | |
| 25 | "streamx": "^2.22.1", | |
| 26 | "unslab": "^1.3.0" | |
| 27 | }, | |
| 28 | "devDependencies": { | |
| 29 | "bare-process": "^4.2.2", | |
| 30 | "brittle": "^3.0.2", | |
| 31 | "hypercore-crypto": "^3.4.0", | |
| 32 | "lunte": "^1.3.0", | |
| 33 | "prettier": "^3.6.2", | |
| 34 | "prettier-config-holepunch": "^2.0.0", | |
| 35 | "which-runtime": "^1.3.2" | |
| 36 | }, | |
| 37 | "scripts": { | |
| 38 | "format": "prettier --write .", | |
| 39 | "lint": "prettier --check . && lunte", | |
| 40 | "test": "npm run test:node && npm run test:bare", | |
| 41 | "test:generate": "brittle -r test/all.js test/*.js", | |
| 42 | "test:node": "brittle-node --coverage test/*.js", | |
| 43 | "test:bare": "bare test/all.js" | |
| 44 | }, | |
| 45 | "repository": { | |
| 46 | "type": "git", | |
| 47 | "url": "https://github.com/holepunchto/hyperswarm.git" | |
| 48 | }, | |
| 49 | "author": "Mathias Buus (@mafintosh)", | |
| 50 | "contributors": [ | |
| 51 | "David Mark Clements (@davidmarkclem)", | |
| 52 | "Andrew Osheroff (@andrewosh)" | |
| 53 | ], | |
| 54 | "license": "MIT", | |
| 55 | "bugs": { | |
| 56 | "url": "https://github.com/holepunchto/hyperswarm/issues" | |
| 57 | }, | |
| 58 | "homepage": "https://github.com/holepunchto/hyperswarm" | |
| 59 | } |
| … | … | @@ -0,0 +1,6 @@ |
| 1 | language: node_js | |
| 2 | node_js: | |
| 3 | - '6' | |
| 4 | - '8' | |
| 5 | - '10' | |
| 6 |
| … | … | @@ -0,0 +1,21 @@ |
| 1 | The MIT License (MIT) | |
| 2 | ||
| 3 | Copyright (c) 2017 Mathias Buus | |
| 4 | ||
| 5 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | of this software and associated documentation files (the "Software"), to deal | |
| 7 | in the Software without restriction, including without limitation the rights | |
| 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | copies of the Software, and to permit persons to whom the Software is | |
| 10 | furnished to do so, subject to the following conditions: | |
| 11 | ||
| 12 | The above copyright notice and this permission notice shall be included in | |
| 13 | all copies or substantial portions of the Software. | |
| 14 | ||
| 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | |
| 21 | THE SOFTWARE. |
| … | … | @@ -0,0 +1,116 @@ |
| 1 | # shuffled-priority-queue | |
| 2 | ||
| 3 | A priority queue that shuffles elements with the same priority. | |
| 4 | ||
| 5 | ``` | |
| 6 | npm install shuffled-priority-queue | |
| 7 | ``` | |
| 8 | ||
| 9 | [](https://travis-ci.org/mafintosh/shuffled-priority-queue) | |
| 10 | ||
| 11 | ## Usage | |
| 12 | ||
| 13 | ``` js | |
| 14 | const spq = require('shuffled-priority-queue') | |
| 15 | const queue = spq() | |
| 16 | ||
| 17 | queue.add({ | |
| 18 | priority: 0, | |
| 19 | value: 'hello' | |
| 20 | }) | |
| 21 | ||
| 22 | queue.add({ | |
| 23 | priority: 0, | |
| 24 | value: 'world' | |
| 25 | }) | |
| 26 | ||
| 27 | queue.add({ | |
| 28 | priority: 1, | |
| 29 | value: 'welt' | |
| 30 | }) | |
| 31 | ||
| 32 | queue.add({ | |
| 33 | priority: 2, | |
| 34 | value: 'verden' | |
| 35 | }) | |
| 36 | ||
| 37 | console.log(queue.shift()) // returns {value: 'verden'} | |
| 38 | console.log(queue.shift()) // returns {value: 'welt'} | |
| 39 | console.log(queue.shift()) // returns {value: 'hello'} or {value: 'world'} | |
| 40 | console.log(queue.shift()) // returns {value: 'hello'} or {value: 'world'} | |
| 41 | console.log(queue.shift()) // returns null (empty queue) | |
| 42 | ``` | |
| 43 | ||
| 44 | ## API | |
| 45 | ||
| 46 | #### `const queue = spq()` | |
| 47 | ||
| 48 | Create a new queue. | |
| 49 | ||
| 50 | #### `value = queue.add(value)` | |
| 51 | ||
| 52 | Add a new value to the queue. The value is returned for convenience | |
| 53 | If you set `value.priority` to a number, it'll be added to the queue at that priority. | |
| 54 | ||
| 55 | #### `queue.remove(value)` | |
| 56 | ||
| 57 | Remove a value from the queue. | |
| 58 | ||
| 59 | #### `bool = queue.has(value)` | |
| 60 | ||
| 61 | Check if a value is in the queue. | |
| 62 | ||
| 63 | #### `value = queue.shift()` | |
| 64 | ||
| 65 | Shift the next value off the queue. | |
| 66 | ||
| 67 | The value returned will have the highest priority off the queue. | |
| 68 | If multiple values have the same priority a random one is returned. | |
| 69 | ||
| 70 | #### `value = queue.head()` | |
| 71 | ||
| 72 | Same as `shift()` but does not mutate the queue. | |
| 73 | ||
| 74 | #### `value = queue.pop()` | |
| 75 | ||
| 76 | Same as `shift()` but returns a value with the lowest priority. | |
| 77 | ||
| 78 | #### `value = queue.tail()` | |
| 79 | ||
| 80 | Same as `pop()` but does not mutate the queue. | |
| 81 | ||
| 82 | #### `queue.length` | |
| 83 | ||
| 84 | Property containing how many items are in the queue | |
| 85 | ||
| 86 | #### `for (const value of queue)` | |
| 87 | ||
| 88 | Iterate the queue from highest priority to lowest using the `for of` syntax | |
| 89 | ||
| 90 | #### `value = queue.next([prevValue])` | |
| 91 | ||
| 92 | Iterate the queue from highest priority to lowest. | |
| 93 | ||
| 94 | ``` js | |
| 95 | let prevValue = null | |
| 96 | ||
| 97 | while (prevValue = queue.next(prevValue)) { | |
| 98 | console.log('value:', prevValue) | |
| 99 | } | |
| 100 | ``` | |
| 101 | ||
| 102 | #### `value = queue.prev([prevValue])` | |
| 103 | ||
| 104 | Iterate the queue from lowest priority to highest. | |
| 105 | ||
| 106 | ``` js | |
| 107 | let prevValue = null | |
| 108 | ||
| 109 | while (prevValue = queue.prev(prevValue)) { | |
| 110 | console.log('value:', prevValue) | |
| 111 | } | |
| 112 | ``` | |
| 113 | ||
| 114 | ## License | |
| 115 | ||
| 116 | MIT |
| … | … | @@ -0,0 +1,28 @@ |
| 1 | var spq = require('./') | |
| 2 | var queue = spq() | |
| 3 | ||
| 4 | queue.add({ | |
| 5 | priority: 0, | |
| 6 | value: 'hello' | |
| 7 | }) | |
| 8 | ||
| 9 | queue.add({ | |
| 10 | priority: 0, | |
| 11 | value: 'world' | |
| 12 | }) | |
| 13 | ||
| 14 | queue.add({ | |
| 15 | priority: 1, | |
| 16 | value: 'welt' | |
| 17 | }) | |
| 18 | ||
| 19 | queue.add({ | |
| 20 | priority: 2, | |
| 21 | value: 'verden' | |
| 22 | }) | |
| 23 | ||
| 24 | console.log(queue.pop()) // returns {value: 'verden'} | |
| 25 | console.log(queue.pop()) // returns {value: 'welt'} | |
| 26 | console.log(queue.pop()) // returns {value: 'hello'} or {value: 'world'} | |
| 27 | console.log(queue.pop()) // returns {value: 'hello'} or {value: 'world'} | |
| 28 | console.log(queue.pop()) // returns null (empty queue) |
| … | … | @@ -0,0 +1,134 @@ |
| 1 | const set = require('unordered-set') | |
| 2 | ||
| 3 | module.exports = opts => new ShuffledPriorityQueue(opts) | |
| 4 | ||
| 5 | class ShuffledPriorityQueue { | |
| 6 | constructor (opts) { | |
| 7 | this.priorities = [] | |
| 8 | this.equals = (opts && opts.equals) || null | |
| 9 | } | |
| 10 | ||
| 11 | get length () { | |
| 12 | return this.priorities.reduce(add, 0) | |
| 13 | } | |
| 14 | ||
| 15 | [Symbol.iterator] () { | |
| 16 | return new Iterator(this) | |
| 17 | } | |
| 18 | ||
| 19 | head () { | |
| 20 | for (let i = this.priorities.length - 1; i >= 0; i--) { | |
| 21 | const q = this.priorities[i] | |
| 22 | if (q.length) return shuffle(q, 0) | |
| 23 | } | |
| 24 | return null | |
| 25 | } | |
| 26 | ||
| 27 | tail () { | |
| 28 | for (let i = 0; i < this.priorities.length; i++) { | |
| 29 | const q = this.priorities[i] | |
| 30 | if (q.length) return shuffle(q, 0) | |
| 31 | } | |
| 32 | return null | |
| 33 | } | |
| 34 | ||
| 35 | prev (prev) { | |
| 36 | if (!prev) return this.tail() | |
| 37 | return next(this.priorities, prev, 1) | |
| 38 | } | |
| 39 | ||
| 40 | next (prev) { | |
| 41 | if (!prev) return this.head() | |
| 42 | return next(this.priorities, prev, -1) | |
| 43 | } | |
| 44 | ||
| 45 | shift () { | |
| 46 | return this.remove(this.head()) | |
| 47 | } | |
| 48 | ||
| 49 | pop () { | |
| 50 | return this.remove(this.tail()) | |
| 51 | } | |
| 52 | ||
| 53 | add (val) { | |
| 54 | const prio = val.priority || 0 | |
| 55 | while (prio >= this.priorities.length) this.priorities.push([]) | |
| 56 | set.add(this.priorities[prio], val) | |
| 57 | return val | |
| 58 | } | |
| 59 | ||
| 60 | remove (val) { | |
| 61 | if (!val) return null | |
| 62 | ||
| 63 | if (val._index === undefined) { | |
| 64 | val = this.find(val) | |
| 65 | if (!val) return null | |
| 66 | } | |
| 67 | ||
| 68 | return set.remove(this.priorities[val.priority || 0], val) | |
| 69 | } | |
| 70 | ||
| 71 | has (val) { | |
| 72 | if (val._index === undefined) return this.find(val) | |
| 73 | const priority = val.priority || 0 | |
| 74 | if (priority >= this.priorities.length) return false | |
| 75 | return set.has(this.priorities[priority], val) | |
| 76 | } | |
| 77 | ||
| 78 | find (val) { | |
| 79 | if (val._index !== undefined) return val | |
| 80 | ||
| 81 | const prio = val.priority || 0 | |
| 82 | const qs = this.priorities | |
| 83 | if (prio >= qs.length) return null | |
| 84 | ||
| 85 | const q = qs[prio] | |
| 86 | ||
| 87 | for (let i = 0; i < q.length; i++) { | |
| 88 | if (this.equals(q[i], val)) return q[i] | |
| 89 | } | |
| 90 | ||
| 91 | return null | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | class Iterator { | |
| 96 | constructor (queue) { | |
| 97 | this.prev = null | |
| 98 | this.queue = queue | |
| 99 | } | |
| 100 | ||
| 101 | next () { | |
| 102 | const next = this.queue.next(this.prev) | |
| 103 | this.prev = next | |
| 104 | return { done: !next, value: next } | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | function shuffle (q, i) { | |
| 109 | const ran = i + Math.floor(Math.random() * (q.length - i)) | |
| 110 | set.swap(q, q[ran], q[i]) | |
| 111 | return q[i] | |
| 112 | } | |
| 113 | ||
| 114 | function next (queues, prev, inc) { | |
| 115 | let i = prev.priority || 0 | |
| 116 | let j = (prev._index || 0) + 1 | |
| 117 | ||
| 118 | while (true) { | |
| 119 | if (i < 0 || i >= queues.length) return null | |
| 120 | const q = queues[i] | |
| 121 | ||
| 122 | if (j >= q.length) { | |
| 123 | i += inc | |
| 124 | j = 0 | |
| 125 | continue | |
| 126 | } | |
| 127 | ||
| 128 | return shuffle(q, j) | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | function add (len, b) { | |
| 133 | return len + b.length | |
| 134 | } |
| … | … | @@ -0,0 +1,26 @@ |
| 1 | { | |
| 2 | "name": "shuffled-priority-queue", | |
| 3 | "version": "2.1.0", | |
| 4 | "description": "A priority queue that shuffles elements with the same priority.", | |
| 5 | "main": "index.js", | |
| 6 | "scripts": { | |
| 7 | "test": "standard && tape test.js" | |
| 8 | }, | |
| 9 | "dependencies": { | |
| 10 | "unordered-set": "^2.0.1" | |
| 11 | }, | |
| 12 | "devDependencies": { | |
| 13 | "standard": "^12.0.1", | |
| 14 | "tape": "^4.9.1" | |
| 15 | }, | |
| 16 | "repository": { | |
| 17 | "type": "git", | |
| 18 | "url": "https://github.com/mafintosh/shuffled-priority-queue.git" | |
| 19 | }, | |
| 20 | "author": "Mathias Buus (@mafintosh)", | |
| 21 | "license": "MIT", | |
| 22 | "bugs": { | |
| 23 | "url": "https://github.com/mafintosh/shuffled-priority-queue/issues" | |
| 24 | }, | |
| 25 | "homepage": "https://github.com/mafintosh/shuffled-priority-queue" | |
| 26 | } |
| … | … | @@ -0,0 +1,164 @@ |
| 1 | const spq = require('./') | |
| 2 | const tape = require('tape') | |
| 3 | ||
| 4 | tape('different prios', function (t) { | |
| 5 | const queue = spq() | |
| 6 | ||
| 7 | const a = queue.add({ | |
| 8 | hello: 'world', | |
| 9 | priority: 0 | |
| 10 | }) | |
| 11 | ||
| 12 | const b = queue.add({ | |
| 13 | hej: 'verden', | |
| 14 | priority: 1 | |
| 15 | }) | |
| 16 | ||
| 17 | t.ok(queue.has(b)) | |
| 18 | t.same(queue.length, 2) | |
| 19 | t.same(queue.shift(), b) | |
| 20 | t.ok(!queue.has(b)) | |
| 21 | t.same(queue.length, 1) | |
| 22 | t.same(queue.shift(), a) | |
| 23 | t.same(queue.length, 0) | |
| 24 | t.same(queue.shift(), null) | |
| 25 | t.end() | |
| 26 | }) | |
| 27 | ||
| 28 | tape('same prios', function (t) { | |
| 29 | const queue = spq() | |
| 30 | ||
| 31 | const a = queue.add({ | |
| 32 | hello: 'world', | |
| 33 | priority: 0 | |
| 34 | }) | |
| 35 | ||
| 36 | const b = queue.add({ | |
| 37 | hello: 'verden', | |
| 38 | priority: 0 | |
| 39 | }) | |
| 40 | ||
| 41 | const c = queue.add({ | |
| 42 | hej: 'verden', | |
| 43 | priority: 1 | |
| 44 | }) | |
| 45 | ||
| 46 | t.same(queue.shift(), c) | |
| 47 | ||
| 48 | let head = queue.shift() | |
| 49 | t.ok(head === a || head === b) | |
| 50 | ||
| 51 | head = queue.shift() | |
| 52 | t.ok(head === a || head === b) | |
| 53 | ||
| 54 | t.same(queue.shift(), null) | |
| 55 | t.end() | |
| 56 | }) | |
| 57 | ||
| 58 | tape('next', function (t) { | |
| 59 | const queue = spq() | |
| 60 | ||
| 61 | const a = queue.add({ | |
| 62 | hello: 'world', | |
| 63 | priority: 0 | |
| 64 | }) | |
| 65 | ||
| 66 | const b = queue.add({ | |
| 67 | hello: 'verden', | |
| 68 | priority: 0 | |
| 69 | }) | |
| 70 | ||
| 71 | const c = queue.add({ | |
| 72 | hej: 'verden', | |
| 73 | priority: 1 | |
| 74 | }) | |
| 75 | ||
| 76 | t.same(queue.next(), c) | |
| 77 | ||
| 78 | let value = queue.next(c) | |
| 79 | t.ok(value === a || value === b) | |
| 80 | ||
| 81 | const old = value | |
| 82 | value = queue.next(value) | |
| 83 | t.ok(old !== value) | |
| 84 | t.ok(value === a || value === b) | |
| 85 | ||
| 86 | t.same(queue.next(value), null) | |
| 87 | t.end() | |
| 88 | }) | |
| 89 | ||
| 90 | tape('prev', function (t) { | |
| 91 | const queue = spq() | |
| 92 | ||
| 93 | const a = queue.add({ | |
| 94 | hello: 'world', | |
| 95 | priority: 0 | |
| 96 | }) | |
| 97 | ||
| 98 | const b = queue.add({ | |
| 99 | hello: 'verden', | |
| 100 | priority: 0 | |
| 101 | }) | |
| 102 | ||
| 103 | const c = queue.add({ | |
| 104 | hej: 'verden', | |
| 105 | priority: 1 | |
| 106 | }) | |
| 107 | ||
| 108 | let tail = queue.prev() | |
| 109 | t.ok(tail === a || tail === b) | |
| 110 | ||
| 111 | const old = tail | |
| 112 | tail = queue.prev(tail) | |
| 113 | t.ok(old !== tail) | |
| 114 | t.ok(tail === a || tail === b) | |
| 115 | ||
| 116 | tail = queue.prev(tail) | |
| 117 | t.same(tail, c) | |
| 118 | ||
| 119 | t.same(queue.prev(tail), null) | |
| 120 | t.end() | |
| 121 | }) | |
| 122 | ||
| 123 | tape('equals', function (t) { | |
| 124 | const queue = spq({ | |
| 125 | equals: function (a, b) { | |
| 126 | return a.hello === b.hello | |
| 127 | } | |
| 128 | }) | |
| 129 | ||
| 130 | queue.add({ | |
| 131 | hello: 'world' | |
| 132 | }) | |
| 133 | ||
| 134 | t.same(queue.head().hello, 'world') | |
| 135 | ||
| 136 | queue.remove({ | |
| 137 | hello: 'world' | |
| 138 | }) | |
| 139 | ||
| 140 | t.same(queue.head(), null) | |
| 141 | t.end() | |
| 142 | }) | |
| 143 | ||
| 144 | tape('iterator', function (t) { | |
| 145 | t.plan(5) | |
| 146 | ||
| 147 | const queue = spq() | |
| 148 | const seen = {} | |
| 149 | ||
| 150 | queue.add({ priority: 0, hi: 'a' }) | |
| 151 | queue.add({ priority: 0, hi: 'b' }) | |
| 152 | queue.add({ priority: 1, hi: 'c' }) | |
| 153 | queue.add({ priority: 2, hi: 'd' }) | |
| 154 | ||
| 155 | let prev = 3 | |
| 156 | ||
| 157 | for (const value of queue) { | |
| 158 | t.ok(prev >= value.priority) | |
| 159 | prev = value.priority | |
| 160 | seen[value.hi] = true | |
| 161 | } | |
| 162 | ||
| 163 | t.same(seen, { a: true, b: true, c: true, d: true }) | |
| 164 | }) |
| … | … | @@ -0,0 +1,9 @@ |
| 1 | sudo: false | |
| 2 | ||
| 3 | language: node_js | |
| 4 | ||
| 5 | node_js: | |
| 6 | - "5" | |
| 7 | - "4" | |
| 8 | - "0.12" | |
| 9 | - "0.10" |
| … | … | @@ -0,0 +1,21 @@ |
| 1 | The MIT License (MIT) | |
| 2 | ||
| 3 | Copyright (c) 2016 Mathias Buus | |
| 4 | ||
| 5 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | of this software and associated documentation files (the "Software"), to deal | |
| 7 | in the Software without restriction, including without limitation the rights | |
| 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | copies of the Software, and to permit persons to whom the Software is | |
| 10 | furnished to do so, subject to the following conditions: | |
| 11 | ||
| 12 | The above copyright notice and this permission notice shall be included in | |
| 13 | all copies or substantial portions of the Software. | |
| 14 | ||
| 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | |
| 21 | THE SOFTWARE. |
| … | … | @@ -0,0 +1,53 @@ |
| 1 | # unordered-set | |
| 2 | ||
| 3 | A couple of functions that make it easy to maintain an unordered set as an array in an efficient way | |
| 4 | ||
| 5 | ``` | |
| 6 | npm install unordered-set | |
| 7 | ``` | |
| 8 | ||
| 9 | [](https://travis-ci.org/mafintosh/unordered-set) | |
| 10 | ||
| 11 | ## Usage | |
| 12 | ||
| 13 | ``` js | |
| 14 | var set = require('unordered-set') | |
| 15 | var list = [] | |
| 16 | ||
| 17 | var a = {hello: 'world'} | |
| 18 | var b = {hej: 'verden'} | |
| 19 | ||
| 20 | set.add(list, a) | |
| 21 | set.add(list, b) | |
| 22 | ||
| 23 | console.log(list) // prints a and b | |
| 24 | ||
| 25 | set.remove(list, a) | |
| 26 | ||
| 27 | console.log(list) // prints b | |
| 28 | ``` | |
| 29 | ||
| 30 | Items are removed and added using the same technique as in [unordered-array-remove](https://github.com/mafintosh/unordered-array-remove) | |
| 31 | making the removal/additions run in O(1). | |
| 32 | ||
| 33 | ## API | |
| 34 | ||
| 35 | #### `set.add(list, item)` | |
| 36 | ||
| 37 | Add an item. Notes that this sets the property `._index` to a number. If you control the items you insert and this is performance critical it might be benefitial to set `item._index = 0` in the item constructor as v8 tends to like that. | |
| 38 | ||
| 39 | #### `set.remove(list, item)` | |
| 40 | ||
| 41 | Remove an item from the set. Might change the order of the list as well. | |
| 42 | ||
| 43 | #### `bool = set.has(list, item)` | |
| 44 | ||
| 45 | Returns `true` if the item is in the list and `false` otherwise | |
| 46 | ||
| 47 | #### `set.swap(list, a, b)` | |
| 48 | ||
| 49 | Swap the positions of two elements in the set | |
| 50 | ||
| 51 | ## License | |
| 52 | ||
| 53 | MIT |
| … | … | @@ -0,0 +1,36 @@ |
| 1 | exports.add = add | |
| 2 | exports.has = has | |
| 3 | exports.remove = remove | |
| 4 | exports.swap = swap | |
| 5 | ||
| 6 | function add (list, item) { | |
| 7 | if (has(list, item)) return item | |
| 8 | item._index = list.length | |
| 9 | list.push(item) | |
| 10 | return item | |
| 11 | } | |
| 12 | ||
| 13 | function has (list, item) { | |
| 14 | return item._index < list.length && list[item._index] === item | |
| 15 | } | |
| 16 | ||
| 17 | function remove (list, item) { | |
| 18 | if (!has(list, item)) return null | |
| 19 | ||
| 20 | var last = list.pop() | |
| 21 | if (last !== item) { | |
| 22 | list[item._index] = last | |
| 23 | last._index = item._index | |
| 24 | } | |
| 25 | ||
| 26 | return item | |
| 27 | } | |
| 28 | ||
| 29 | function swap (list, a, b) { | |
| 30 | if (!has(list, a) || !has(list, b)) return | |
| 31 | var tmp = a._index | |
| 32 | a._index = b._index | |
| 33 | list[a._index] = a | |
| 34 | b._index = tmp | |
| 35 | list[b._index] = b | |
| 36 | } |
| … | … | @@ -0,0 +1,24 @@ |
| 1 | { | |
| 2 | "name": "unordered-set", | |
| 3 | "version": "2.0.1", | |
| 4 | "description": "A couple of functions that make it easy to maintain an unordered set as an array in an efficient way", | |
| 5 | "main": "index.js", | |
| 6 | "dependencies": {}, | |
| 7 | "devDependencies": { | |
| 8 | "standard": "^6.0.4", | |
| 9 | "tape": "^4.4.0" | |
| 10 | }, | |
| 11 | "scripts": { | |
| 12 | "test": "standard && tape test.js" | |
| 13 | }, | |
| 14 | "repository": { | |
| 15 | "type": "git", | |
| 16 | "url": "https://github.com/mafintosh/unordered-set.git" | |
| 17 | }, | |
| 18 | "author": "Mathias Buus (@mafintosh)", | |
| 19 | "license": "MIT", | |
| 20 | "bugs": { | |
| 21 | "url": "https://github.com/mafintosh/unordered-set/issues" | |
| 22 | }, | |
| 23 | "homepage": "https://github.com/mafintosh/unordered-set" | |
| 24 | } |
| … | … | @@ -0,0 +1,126 @@ |
| 1 | var tape = require('tape') | |
| 2 | var set = require('./') | |
| 3 | ||
| 4 | tape('add', function (t) { | |
| 5 | var list = [] | |
| 6 | var a = {hello: 'world'} | |
| 7 | var b = {hello: 'verden'} | |
| 8 | ||
| 9 | set.add(list, a) | |
| 10 | set.add(list, b) | |
| 11 | ||
| 12 | t.same(list, [a, b]) | |
| 13 | t.end() | |
| 14 | }) | |
| 15 | ||
| 16 | tape('add twice', function (t) { | |
| 17 | var list = [] | |
| 18 | var a = {hello: 'world'} | |
| 19 | var b = {hello: 'verden'} | |
| 20 | ||
| 21 | set.add(list, a) | |
| 22 | set.add(list, b) | |
| 23 | set.add(list, b) | |
| 24 | ||
| 25 | t.same(list, [a, b]) | |
| 26 | t.end() | |
| 27 | }) | |
| 28 | ||
| 29 | tape('remove', function (t) { | |
| 30 | var list = [] | |
| 31 | var a = {hello: 'world'} | |
| 32 | var b = {hello: 'verden'} | |
| 33 | ||
| 34 | set.add(list, a) | |
| 35 | set.add(list, b) | |
| 36 | set.remove(list, a) | |
| 37 | ||
| 38 | t.same(list, [b]) | |
| 39 | t.end() | |
| 40 | }) | |
| 41 | ||
| 42 | tape('remove twice', function (t) { | |
| 43 | var list = [] | |
| 44 | var a = {hello: 'world'} | |
| 45 | var b = {hello: 'verden'} | |
| 46 | ||
| 47 | set.add(list, a) | |
| 48 | set.add(list, b) | |
| 49 | set.remove(list, a) | |
| 50 | set.remove(list, a) | |
| 51 | ||
| 52 | t.same(list, [b]) | |
| 53 | t.end() | |
| 54 | }) | |
| 55 | ||
| 56 | tape('remove all', function (t) { | |
| 57 | var list = [] | |
| 58 | var a = {hello: 'world'} | |
| 59 | var b = {hello: 'verden'} | |
| 60 | ||
| 61 | set.add(list, a) | |
| 62 | set.add(list, b) | |
| 63 | set.remove(list, a) | |
| 64 | set.remove(list, b) | |
| 65 | ||
| 66 | t.same(list, []) | |
| 67 | t.end() | |
| 68 | }) | |
| 69 | ||
| 70 | tape('re-add', function (t) { | |
| 71 | var list = [] | |
| 72 | var a = {hello: 'world'} | |
| 73 | var b = {hello: 'verden'} | |
| 74 | ||
| 75 | set.add(list, a) | |
| 76 | set.add(list, b) | |
| 77 | set.remove(list, a) | |
| 78 | set.remove(list, b) | |
| 79 | set.add(list, b) | |
| 80 | set.add(list, a) | |
| 81 | ||
| 82 | t.same(list, [b, a]) | |
| 83 | t.end() | |
| 84 | }) | |
| 85 | ||
| 86 | tape('has', function (t) { | |
| 87 | var list = [] | |
| 88 | var a = {hello: 'world'} | |
| 89 | var b = {hello: 'verden'} | |
| 90 | ||
| 91 | set.add(list, a) | |
| 92 | set.add(list, b) | |
| 93 | set.remove(list, a) | |
| 94 | ||
| 95 | t.ok(!set.has(list, a)) | |
| 96 | t.ok(set.has(list, b)) | |
| 97 | t.end() | |
| 98 | }) | |
| 99 | ||
| 100 | tape('swap', function (t) { | |
| 101 | var list = [] | |
| 102 | var a = {hello: 'world'} | |
| 103 | var b = {hello: 'verden'} | |
| 104 | ||
| 105 | set.add(list, a) | |
| 106 | set.add(list, b) | |
| 107 | ||
| 108 | set.swap(list, a, b) | |
| 109 | t.same(list, [b, a]) | |
| 110 | ||
| 111 | set.swap(list, a, b) | |
| 112 | t.same(list, [a, b]) | |
| 113 | ||
| 114 | t.end() | |
| 115 | }) | |
| 116 | ||
| 117 | tape('swap same', function (t) { | |
| 118 | var list = [] | |
| 119 | var a = {hello: 'world'} | |
| 120 | ||
| 121 | set.add(list, a) | |
| 122 | ||
| 123 | set.swap(list, a, a) | |
| 124 | t.same(list, [a]) | |
| 125 | t.end() | |
| 126 | }) |
| … | … | @@ -13,6 +13,7 @@ |
| 13 | 13 | "compact-encoding": "^2.19.0", |
| 14 | 14 | "hypercore-crypto": "^3.6.1", |
| 15 | 15 | "hyperdht": "^6.34.0", |
| 16 | "hyperswarm": "^4.17.1", | |
| 16 | 17 | "sodium-universal": "^5.0.1" |
| 17 | 18 | } |
| 18 | 19 | }, |
| … | … | @@ -389,6 +390,21 @@ |
| 389 | 390 | "b4a": "^1.3.0" |
| 390 | 391 | } |
| 391 | 392 | }, |
| 393 | "node_modules/hyperswarm": { | |
| 394 | "version": "4.17.1", | |
| 395 | "resolved": "https://registry.npmjs.org/hyperswarm/-/hyperswarm-4.17.1.tgz", | |
| 396 | "integrity": "sha512-bFy89nulBSY1qQZMZMGWb6wwBHftthONZr5IpX4o7hcSmMQI5IHyfA7Id3KU03SDflWfPvEm1YJy0AL5BFFblA==", | |
| 397 | "license": "MIT", | |
| 398 | "dependencies": { | |
| 399 | "b4a": "^1.3.1", | |
| 400 | "bare-events": "^2.2.0", | |
| 401 | "hyperdht": "^6.21.0", | |
| 402 | "safety-catch": "^1.0.2", | |
| 403 | "shuffled-priority-queue": "^2.1.0", | |
| 404 | "streamx": "^2.22.1", | |
| 405 | "unslab": "^1.3.0" | |
| 406 | } | |
| 407 | }, | |
| 392 | 408 | "node_modules/is-property": { |
| 393 | 409 | "version": "1.0.2", |
| 394 | 410 | "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", |
| … | … | @@ -484,6 +500,15 @@ |
| 484 | 500 | "integrity": "sha512-C1UYVZ4dtbBxEtvOcpjBaaD27nP8MlvyAQEp2fOTOEe6pfUpk1cDUxij6BR1jZup6rSyUTaBBplK7LanskrULA==", |
| 485 | 501 | "license": "MIT" |
| 486 | 502 | }, |
| 503 | "node_modules/shuffled-priority-queue": { | |
| 504 | "version": "2.1.0", | |
| 505 | "resolved": "https://registry.npmjs.org/shuffled-priority-queue/-/shuffled-priority-queue-2.1.0.tgz", | |
| 506 | "integrity": "sha512-xhdh7fHyMsr0m/w2kDfRJuBFRS96b9l8ZPNWGaQ+PMvnUnZ/Eh+gJJ9NsHBd7P9k0399WYlCLzsy18EaMfyadA==", | |
| 507 | "license": "MIT", | |
| 508 | "dependencies": { | |
| 509 | "unordered-set": "^2.0.1" | |
| 510 | } | |
| 511 | }, | |
| 487 | 512 | "node_modules/signal-promise": { |
| 488 | 513 | "version": "1.0.3", |
| 489 | 514 | "resolved": "https://registry.npmjs.org/signal-promise/-/signal-promise-1.0.3.tgz", |
| … | … | @@ -586,6 +611,12 @@ |
| 586 | 611 | "bare": ">=1.17.4" |
| 587 | 612 | } |
| 588 | 613 | }, |
| 614 | "node_modules/unordered-set": { | |
| 615 | "version": "2.0.1", | |
| 616 | "resolved": "https://registry.npmjs.org/unordered-set/-/unordered-set-2.0.1.tgz", | |
| 617 | "integrity": "sha512-eUmNTPzdx+q/WvOHW0bgGYLWvWHNT3PTKEQLg0MAQhc0AHASHVHoP/9YytYd4RBVariqno/mEUhVZN98CmD7bg==", | |
| 618 | "license": "MIT" | |
| 619 | }, | |
| 589 | 620 | "node_modules/unslab": { |
| 590 | 621 | "version": "1.3.0", |
| 591 | 622 | "resolved": "https://registry.npmjs.org/unslab/-/unslab-1.3.0.tgz", |
| … | … | @@ -15,6 +15,7 @@ |
| 15 | 15 | "compact-encoding": "^2.19.0", |
| 16 | 16 | "hypercore-crypto": "^3.6.1", |
| 17 | 17 | "hyperdht": "^6.34.0", |
| 18 | "hyperswarm": "^4.17.1", | |
| 18 | 19 | "sodium-universal": "^5.0.1" |
| 19 | 20 | } |
| 20 | 21 | } |