diff --git a/package-lock.json b/package-lock.json index 7eab9afb8..547cf1d4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@mapeo/core", "version": "9.0.0-alpha.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { "@digidem/types": "^2.0.0", @@ -41,9 +42,10 @@ "protobufjs": "^7.2.3", "protomux": "^3.4.1", "sodium-universal": "^4.0.0", - "start-stop-state-machine": "^1.1.1", + "start-stop-state-machine": "^1.2.0", "sub-encoder": "^2.1.1", - "tiny-typed-emitter": "^2.1.0" + "tiny-typed-emitter": "^2.1.0", + "z32": "^1.0.1" }, "devDependencies": { "@bufbuild/buf": "^1.26.1", @@ -6900,9 +6902,9 @@ "license": "MIT" }, "node_modules/start-stop-state-machine": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/start-stop-state-machine/-/start-stop-state-machine-1.1.1.tgz", - "integrity": "sha512-/zp12bOXH0sOHhnzHjNFtjp7pkkgI3QNzJs78dYVL9jjZiB/oizIv+1u2fRfzNB9kpt+D/3LIwaWRogOlnVp+Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/start-stop-state-machine/-/start-stop-state-machine-1.2.0.tgz", + "integrity": "sha512-U9OtWHh+YKPqXHPZc5Ziz5+P/bdKFq14Lz8GnsPXyxNN6RC18nvJ0QAey5FfDpW9DDSaakByrQ4VcnPYm4a+YA==", "dependencies": { "tiny-typed-emitter": "^2.1.0" } @@ -7765,8 +7767,9 @@ } }, "node_modules/z32": { - "version": "1.0.0", - "license": "MIT", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/z32/-/z32-1.0.1.tgz", + "integrity": "sha512-Uytfqf6VEVchHKZDw0NRdCViOARHP84uzvOw0CXCMLOwhgHZUL9XibpEPLLQN10mCVLxOlGCQWbkV7km7yNYcw==", "dependencies": { "b4a": "^1.5.3" } diff --git a/package.json b/package.json index 3df3520e7..b1a95ba7f 100644 --- a/package.json +++ b/package.json @@ -126,8 +126,9 @@ "protobufjs": "^7.2.3", "protomux": "^3.4.1", "sodium-universal": "^4.0.0", - "start-stop-state-machine": "^1.1.1", + "start-stop-state-machine": "^1.2.0", "sub-encoder": "^2.1.1", - "tiny-typed-emitter": "^2.1.0" + "tiny-typed-emitter": "^2.1.0", + "z32": "^1.0.1" } } diff --git a/src/discovery/mdns.js b/src/discovery/mdns.js index a70f4fd5f..719c95053 100644 --- a/src/discovery/mdns.js +++ b/src/discovery/mdns.js @@ -2,199 +2,277 @@ import { TypedEmitter } from 'tiny-typed-emitter' import net from 'node:net' import NoiseSecretStream from '@hyperswarm/secret-stream' import { once } from 'node:events' -import dnssd from '@gravitysoftware/dnssd' +import { DnsSd } from './dns-sd.js' import debug from 'debug' -import { randomBytes } from 'node:crypto' import isIpPrivate from 'private-ip' - -const log = debug('mdns') - -const SERVICE_NAME = 'mapeo' +import StartStopStateMachine from 'start-stop-state-machine' +import pTimeout from 'p-timeout' +import { projectKeyToPublicId as keyToPublicId } from '@mapeo/crypto' /** @typedef {{ publicKey: Buffer, secretKey: Buffer }} Keypair */ +export const ERR_DUPLICATE = 'Duplicate connection' + /** - * @typedef {Object} Service - * @property {string} host - hostname of the service - * @property {number} port - port of the service - * @property {Object} txt - TXT records of the service - * @property {string[]} [addresses] - addresses of the service + * @typedef {Object} DiscoveryEvents + * @property {(connection: import('@hyperswarm/secret-stream')) => void} connection */ +/** + * @extends {TypedEmitter} + */ export class MdnsDiscovery extends TypedEmitter { #identityKeypair #server - /** @type {Map>} */ + /** @type {Map>} */ #noiseConnections = new Map() - /** @type {import('@gravitysoftware/dnssd').Advertisement} */ - #advertiser - /** @type {import('@gravitysoftware/dnssd').Browser} */ - #browser - /** @type {string} */ - #id = randomBytes(8).toString('hex') - - /** @param {Object} opts + #dnssd + #sm + #log + /** @type {(e: Error) => void} */ + #handleSocketError + + /** + * @param {Object} opts * @param {Keypair} opts.identityKeypair + * @param {DnsSd} [opts.dnssd] Optional DnsSd instance, used for testing */ - constructor({ identityKeypair }) { + constructor({ identityKeypair, dnssd }) { super() + this.#dnssd = + dnssd || + new DnsSd({ + name: keyToPublicId(identityKeypair.publicKey), + }) + this.#dnssd.on('up', this.#handleServiceUp.bind(this)) + this.#log = debug('mapeo:mdns:' + keyShortname(identityKeypair.publicKey)) + this.#sm = new StartStopStateMachine({ + start: this.#start.bind(this), + stop: this.#stop.bind(this), + }) + this.#handleSocketError = (e) => { + this.#log('socket error', e.message) + } this.#identityKeypair = identityKeypair - this.#server = net.createServer(this.#handleConnection.bind(this, false)) + this.#server = net.createServer(this.#handleTcpConnection.bind(this, false)) + this.#server.on('error', (e) => { + this.#log('Server error', e) + }) + } + + get publicKey() { + return this.#identityKeypair.publicKey + } + + address() { + return this.#server.address() } async start() { + return this.#sm.start() + } + + async #start() { + // start browsing straight away + this.#dnssd.browse() + // Let OS choose port, listen on ip4, all interfaces this.#server.listen(0, '0.0.0.0') - this.#server.on('error', () => {}) - this.#server.on('close', () => {}) await once(this.#server, 'listening') + const addr = getAddress(this.#server) + this.#log('server listening on port ' + addr.port) + await this.#dnssd.advertise(addr.port) + this.#log('advertising service on port ' + addr.port) + } - const addr = this.#server.address() - if (!isAddressInfo(addr)) - throw new Error('Server must be listening on a port') - log('server listening on port ' + addr.port) - - this.#advertiser = new dnssd.Advertisement( - dnssd.tcp(SERVICE_NAME), - addr.port, - { txt: { id: this.#id } } - ) - - // find all peers adverticing Mapeo - this.#browser = new dnssd.Browser(dnssd.tcp(SERVICE_NAME)) - this.#browser.on( - 'serviceUp', - /** @param {Service} service */ - (service) => { - if (service.txt?.id === this.#id) { - log(`Discovered self, ignore`) - return - } - log( - 'serviceUp', - addr.port, - addr.address, - service.port, - service.addresses - ) - if (!isValidServerAddresses(service.addresses)) { - throw new Error('Got invalid server addresses from service') - } - // skip ipv6 addresses - const address = service.addresses?.reduce( - (finalAddr, addr) => (net.isIPv4(addr) ? addr : finalAddr), - '' - ) - // const ipv4s = service.addresses?.filter((addr) => net.isIPv4(addr)) - // if (ipv4s.length === 0) return - const socket = net.connect(service.port, address) - socket.once('connect', () => { - this.#handleConnection(true, socket) - }) - } - ) - - Promise.all([this.#advertiser.start(), this.#browser.start()]).finally( - () => { - log(`started advertiser and browser for ${addr.port}`) - } - ) + /** + * + * @param {import('./dns-sd.js').MapeoService} service + * @returns + */ + #handleServiceUp({ address, port, name }) { + this.#log('serviceUp', name.slice(0, 7), address, port) + if (this.#noiseConnections.has(name)) { + this.#log(`Already connected to ${name.slice(0, 7)}`) + return + } + const socket = net.connect(port, address) + socket.on('error', this.#handleSocketError) + socket.once('connect', () => { + this.#handleTcpConnection(true, socket) + }) } /** * @param {boolean} isInitiator * @param {net.Socket} socket */ - async #handleConnection(isInitiator, socket) { - log( - `${isInitiator ? 'outgoing' : 'incoming'} connection ${ + #handleTcpConnection(isInitiator, socket) { + const { remoteAddress } = socket + if (!remoteAddress || !isIpPrivate(remoteAddress)) { + socket.destroy(new Error('Invalid remoteAddress ' + remoteAddress)) + return + } + this.#log( + `${isInitiator ? 'outgoing' : 'incoming'} tcp connection ${ isInitiator ? 'to' : 'from' - } ${socket.remotePort}` + } ${remoteAddress}` ) - if (!socket.remoteAddress) return - if (!isIpPrivate(socket.remoteAddress)) return - - const { remoteAddress } = socket const secretStream = new NoiseSecretStream(isInitiator, socket, { keyPair: this.#identityKeypair, }) - secretStream.on('connect', async () => { - log(`${isInitiator ? 'outgoing' : 'incoming'} secretSteam connection`) - const { remotePublicKey } = secretStream - if (!remotePublicKey) throw new Error('No remote public key') - const remoteId = remotePublicKey.toString('hex') + secretStream.on('error', this.#handleSocketError) - const close = () => { - if (this.#noiseConnections.has(remoteId)) { - this.#noiseConnections.delete(remoteId) - } - log( - `Destroying connection ${isInitiator ? 'to' : 'from'} ${ - socket.remotePort - }` - ) - secretStream.destroy() - socket.destroy() - } + secretStream.on('connect', () => { + // Further errors will be handled in #handleNoiseStreamConnection() + secretStream.off('error', this.#handleSocketError) + this.#handleNoiseStreamConnection(secretStream) + }) + } + + /** + * + * @param {NoiseSecretStream} existing + * @param {NoiseSecretStream} keeping + */ + #handleConnectionSwap(existing, keeping) { + let closed = false - secretStream.on('close', () => close()) - secretStream.on('error', () => close()) + existing.on('close', () => { + // The connection we are keeping could have closed before we get here + if (closed) return - const existing = this.#noiseConnections.get(remoteId) + keeping.removeListener('error', noop) + keeping.removeListener('close', onclose) - if (existing) { - const keepExisting = - (isInitiator && existing.isInitiator) || - (!isInitiator && !existing.isInitiator) || - Buffer.compare(this.#identityKeypair.publicKey, remotePublicKey) - if (keepExisting) { - log(`keeping existing, destroying new`) - socket.destroy() - secretStream.destroy() - return - } else { - log(`destroying existing, keeping new`) - existing.destroy() - } + this.#handleNoiseStreamConnection(keeping) + }) + + keeping.on('error', noop) + keeping.on('close', onclose) + + function onclose() { + closed = true + } + } + + /** + * + * @param {NoiseSecretStream} conn + * @returns + */ + #handleNoiseStreamConnection(conn) { + const { remotePublicKey, isInitiator } = conn + if (!remotePublicKey) { + // Shouldn't get here + this.#log('Error: incoming connection with no publicKey') + conn.destroy() + return + } + const remoteId = keyToPublicId(remotePublicKey) + + this.#log( + `${isInitiator ? 'outgoing' : 'incoming'} secretSteam connection ${ + isInitiator ? 'to' : 'from' + } ${keyShortname(remotePublicKey)}` + ) + + const existing = this.#noiseConnections.get(remoteId) + + if (existing) { + const keepExisting = + (isInitiator && existing.isInitiator) || + (!isInitiator && !existing.isInitiator) || + Buffer.compare(this.#identityKeypair.publicKey, remotePublicKey) > 0 + if (keepExisting) { + this.#log(`keeping existing, destroying new`) + conn.on('error', noop) + conn.destroy(new Error(ERR_DUPLICATE)) + return + } else { + this.#log(`destroying existing, keeping new`) + existing.on('error', noop) + existing.destroy(new Error(ERR_DUPLICATE)) + this.#handleConnectionSwap(existing, conn) + return } - this.#noiseConnections.set(remoteId, secretStream) - this.emit('connection', secretStream) + } + this.#noiseConnections.set(remoteId, conn) + + conn.on('close', () => { + this.#log(`closed connection with ${keyShortname(remotePublicKey)}`) + this.#noiseConnections.delete(remoteId) }) + + // No 'error' listeners attached to `conn` at this point, it's up to the + // consumer to attach an 'error' listener to avoid uncaught errors. + this.emit('connection', conn) } get connections() { return this.#noiseConnections.values() } - async stop() { - const port = this.#server.address()?.port - this.#browser.removeAllListeners('serviceUp') - this.#browser.stop() - this.#advertiser.stop(true) - // eslint-disable-next-line no-unused-vars - for (const [_, socket] of this.#noiseConnections) { - socket.destroy() - } - await this.#server.close() - log(`stopped for ${port}`) + /** + * Close all servers and stop multicast advertising and browsing. Will wait + * for open sockets to close unless opts.force=true in which case open sockets + * are force-closed after opts.timeout milliseconds + * + * @param {object} [opts] + * @param {boolean} [opts.force=false] Force-close open sockets after timeout milliseconds + * @param {number} [opts.timeout=0] Optional timeout when calling stop() with force=true + * @returns {Promise} + */ + async stop(opts) { + return this.#sm.stop(opts) + } + + /** + * @type {MdnsDiscovery['stop']} + */ + async #stop({ force = false, timeout = 0 } = {}) { + this.#log('stopping') + const { port } = getAddress(this.#server) + this.#server.close() + const destroyPromise = this.#dnssd.destroy() + const closePromise = once(this.#server, 'close') + await pTimeout(closePromise, { + milliseconds: force ? (timeout === 0 ? 1 : timeout) : Infinity, + fallback: () => { + for (const socket of this.#noiseConnections.values()) { + socket.destroy() + } + return pTimeout(closePromise, { milliseconds: 500 }) + }, + }) + await destroyPromise + this.#log(`stopped for ${port}`) } } /** - * @param {ReturnType} addr - * @returns {addr is net.AddressInfo} + * Get the address of a server, will throw if the server is not yet listening or + * if it is listening on a socket + * @param {import('node:net').Server} server + * @returns */ -function isAddressInfo(addr) { - if (addr === null || typeof addr === 'string') return false - return true +function getAddress(server) { + const addr = server.address() + if (addr === null || typeof addr === 'string') { + throw new Error('Server is not listening on a port') + } + return addr } /** - * @param {string[] | undefined} addr - * @returns {addr is [string, ...string[]]} + * + * @param {Buffer} key + * @returns */ -function isValidServerAddresses(addr) { - return addr?.length !== 0 && addr !== undefined +function keyShortname(key) { + return keyToPublicId(key).slice(0, 7) } + +function noop() {} diff --git a/tests/discovery.js b/tests/discovery.js index 0a72a7e06..e1fa8ddd5 100644 --- a/tests/discovery.js +++ b/tests/discovery.js @@ -1,9 +1,18 @@ +// @ts-check import test from 'brittle' import { randomBytes } from 'node:crypto' +import net from 'node:net' import { KeyManager } from '@mapeo/crypto' -import { MdnsDiscovery } from '../src/discovery/mdns.js' +import { setTimeout as delay } from 'node:timers/promises' +import { projectKeyToPublicId as keyToPublicId } from '@mapeo/crypto' +import { ERR_DUPLICATE, MdnsDiscovery } from '../src/discovery/mdns.js' +import { once } from 'node:events' +import NoiseSecretStream from '@hyperswarm/secret-stream' -test('mdns - discovery', async (t) => { +// Time in ms to wait for mdns messages to propogate +const MDNS_WAIT_TIME = 5000 + +test('mdns - discovery and sharing of data', (t) => { t.plan(2) const identityKeypair1 = new KeyManager(randomBytes(16)).getIdentityKeypair() const identityKeypair2 = new KeyManager(randomBytes(16)).getIdentityKeypair() @@ -11,197 +20,144 @@ test('mdns - discovery', async (t) => { const mdnsDiscovery1 = new MdnsDiscovery({ identityKeypair: identityKeypair1, }) - mdnsDiscovery1.on('connection', async (stream) => { - const remoteKey = stream.remotePublicKey.toString('hex') - const peerKey = identityKeypair2.publicKey.toString('hex') - t.ok(remoteKey === peerKey) - await step() - }) - const mdnsDiscovery2 = new MdnsDiscovery({ identityKeypair: identityKeypair2, }) - mdnsDiscovery2.on('connection', async (stream) => { - const remoteKey = stream.remotePublicKey.toString('hex') - const peerKey = identityKeypair1.publicKey.toString('hex') - t.ok(remoteKey === peerKey) - await step() + const str = 'hi' + + mdnsDiscovery1.on('connection', (stream) => { + stream.write(str) }) - let count = 0 - async function step() { - count++ - if (count === 2) { - // await new Promise((res) => setTimeout(res, 2000)) - mdnsDiscovery1.stop() - mdnsDiscovery2.stop() - } - } + mdnsDiscovery2.on('connection', (stream) => { + stream.on('data', (d) => { + t.is(d.toString(), str, 'expected data written') + Promise.all([ + mdnsDiscovery1.stop({ force: true }), + mdnsDiscovery2.stop({ force: true }), + ]).then(() => { + t.pass('teardown complete') + }) + }) + }) mdnsDiscovery1.start() mdnsDiscovery2.start() }) -test('mdns - discovery and sharing of data', async (t) => { - t.plan(1) - const identityKeypair1 = new KeyManager(randomBytes(16)).getIdentityKeypair() - const identityKeypair2 = new KeyManager(randomBytes(16)).getIdentityKeypair() +test('deduplicate incoming connections', async (t) => { + const localConnections = new Set() + const remoteConnections = new Set() - const mdnsDiscovery1 = new MdnsDiscovery({ - identityKeypair: identityKeypair1, - }) - const mdnsDiscovery2 = new MdnsDiscovery({ - identityKeypair: identityKeypair2, - }) - const str = 'hi' + const localKp = new KeyManager(randomBytes(16)).getIdentityKeypair() + const remoteKp = new KeyManager(randomBytes(16)).getIdentityKeypair() + const discovery = new MdnsDiscovery({ identityKeypair: localKp }) + await discovery.start() - mdnsDiscovery1.on('connection', (stream) => { - stream.write(str) - step() + discovery.on('connection', (conn) => { + localConnections.add(conn) + conn.on('close', () => localConnections.delete(conn)) }) - mdnsDiscovery2.on('connection', (stream) => { - stream.on('data', (d) => { - t.ok(d.toString() === str) - step() + const addrInfo = discovery.address() + for (let i = 0; i < 20; i++) { + noiseConnect(addrInfo, remoteKp).then((conn) => { + conn.on('connect', () => remoteConnections.add(conn)) + conn.on('close', () => remoteConnections.delete(conn)) }) - }) - await mdnsDiscovery1.start() - await mdnsDiscovery2.start() - - let count = 0 - async function step() { - count++ - if (count === 2) { - mdnsDiscovery1.stop() - mdnsDiscovery2.stop() - } } + + await delay(1000) + t.is(localConnections.size, 1) + t.is(remoteConnections.size, 1) + t.alike( + localConnections.values().next().value.handshakeHash, + remoteConnections.values().next().value.handshakeHash + ) + await discovery.stop({ force: true }) }) test(`mdns - discovery of multiple peers with random time instantiation`, async (t) => { - const nPeers = 5 - // lower timeouts can yield a failing test... - const timeout = 7000 - let conns = [] - t.plan(nPeers + 1) + await testMultiple(t, { period: 2000, nPeers: 20 }) +}) - const spawnPeer = async () => { +test(`mdns - discovery of multiple peers instantiated at the same time`, async (t) => { + await testMultiple(t, { period: 2000, nPeers: 20 }) +}) + +/** + * + * @param {net.AddressInfo} addrInfo + * @param {{ publicKey: Buffer, secretKey: Buffer }} keyPair + * @returns + */ +async function noiseConnect({ port, address }, keyPair) { + const socket = net.connect(port, address) + return new NoiseSecretStream(true, socket, { keyPair }) +} + +/** + * @param {any} t + * @param {object} opts + * @param {number} opts.period Randomly spawn peers within this period + * @param {number} [opts.nPeers] Number of peers to spawn (default 20) + */ +async function testMultiple(t, { period, nPeers = 20 }) { + const peersById = new Map() + const connsById = new Map() + // t.plan(3 * nPeers + 1) + + async function spawnPeer() { const identityKeypair = new KeyManager(randomBytes(16)).getIdentityKeypair() const discovery = new MdnsDiscovery({ identityKeypair }) - discovery.on('connection', (stream) => { - conns.push({ - publicKey: identityKeypair.publicKey, - remotePublicKey: stream.remotePublicKey, + const peerId = keyToPublicId(discovery.publicKey) + peersById.set(peerId, discovery) + const conns = [] + connsById.set(peerId, conns) + discovery.on('connection', (conn) => { + conn.on('error', (e) => { + // We expected connections to be closed when duplicates happen. On the + // closing side the error will be ERR_DUPLICATE, but on the other side + // the error will be an ECONNRESET - the error is not sent over the + // connection + const expectedError = + e.message === ERR_DUPLICATE || e.code === 'ECONNRESET' + t.ok(expectedError, 'connection closed with expected error') }) + conns.push(conn) }) - const peer = { - discovery, - publicKey: identityKeypair.publicKey, - } await discovery.start() - return peer + return discovery } - /** @type {{ - * discovery:MdnsDiscovery, - * publicKey: String, - * stream: NoiseSecretStream - * }[]} */ - const peers = [] for (let p = 0; p < nPeers; p++) { - const randTimeout = Math.floor(Math.random() * 2000) - setTimeout(async () => { - peers.push(await spawnPeer()) - }, randTimeout) + setTimeout(spawnPeer, Math.floor(Math.random() * period)) } - setTimeout(async () => { - t.is( - conns.length, - nPeers * (nPeers - 1), - `number of connections match the number of peers (nPeers * (nPeers - 1))` - ) - for (let peer of peers) { - const publicKey = peer.publicKey - const peerConns = conns - .filter(({ publicKey: localKey }) => localKey === publicKey) - .map(({ remotePublicKey }) => remotePublicKey.toString('hex')) - .sort() - const otherConns = peers - .filter(({ publicKey: peerKey }) => publicKey !== peerKey) - .map(({ publicKey }) => publicKey.toString('hex')) - .sort() - - t.alike(otherConns, peerConns, `the set of peer public keys match`) - } - }, timeout) - - t.teardown(async () => { - for (let peer of peers) { - await peer.discovery.stop() - } - t.end() - }) -}) -test(`mdns - discovery of multiple peers with simultaneous instantiation`, async (t) => { - const nPeers = 7 - // lower timeouts can yield a failing test... - const timeout = 7000 - let conns = [] - t.plan(nPeers + 1) - - const spawnPeer = async () => { - const identityKeypair = new KeyManager(randomBytes(16)).getIdentityKeypair() - const discovery = new MdnsDiscovery({ identityKeypair }) - discovery.on('connection', (stream) => { - conns.push({ - publicKey: identityKeypair.publicKey, - remotePublicKey: stream.remotePublicKey, - }) - }) - const peer = { - discovery, - publicKey: identityKeypair.publicKey, - } - await discovery.start() - return peer + await delay(period + MDNS_WAIT_TIME) + + const peerIds = [...peersById.keys()] + + for (const peerId of peerIds) { + const expected = peerIds.filter((id) => id !== peerId).sort() + const actual = connsById + .get(peerId) + .filter((conn) => !conn.destroyed) + .map((conn) => keyToPublicId(conn.remotePublicKey)) + .sort() + t.alike( + actual, + expected, + `peer ${peerId.slice(0, 7)} connected to all ${ + expected.length + } other peers` + ) } - /** @type {{ - * discovery:MdnsDiscovery, - * publicKey: String, - * stream: NoiseSecretStream - * }[]} */ - const peers = [] - for (let p = 0; p < nPeers; p++) { - peers.push(await spawnPeer()) + const stopPromises = [] + for (const discovery of peersById.values()) { + stopPromises.push(discovery.stop({ force: true })) } - setTimeout(async () => { - t.is( - conns.length, - nPeers * (nPeers - 1), - `number of connections match the number of peers (nPeers * (nPeers - 1))` - ) - for (let peer of peers) { - const publicKey = peer.publicKey - const peerConns = conns - .filter(({ publicKey: localKey }) => localKey === publicKey) - .map(({ remotePublicKey }) => remotePublicKey.toString('hex')) - .sort() - const otherConns = peers - .filter(({ publicKey: peerKey }) => publicKey !== peerKey) - .map(({ publicKey }) => publicKey.toString('hex')) - .sort() - - t.alike(otherConns, peerConns, `the set of peer public keys match`) - } - }, timeout) - - t.teardown(async () => { - for (let peer of peers) { - await peer.discovery.stop() - } - t.end() - }) -}) + await Promise.all(stopPromises) + t.pass('teardown complete') +} diff --git a/types/z32.d.ts b/types/z32.d.ts new file mode 100644 index 000000000..b7184d6d4 --- /dev/null +++ b/types/z32.d.ts @@ -0,0 +1,8 @@ +declare module 'z32' { + interface Z32 { + encode(buf: Uint8Array): string + decode(s: string, out?: Uint8Array): Uint8Array | Buffer + } + const z32: Z32 + export = z32 +}