UNPKG

@dao-xyz/peerbit

Version:

Distributed p2p database on IPFS

802 lines (798 loc) 36.3 kB
import LazyLevel from "@dao-xyz/lazy-level"; import { Level } from "level"; import { MemoryLevel } from "memory-level"; import { multiaddr } from "@multiformats/multiaddr"; import { createExchangeHeadsMessage, ExchangeHeadsMessage, AbsolutMinReplicas, } from "./exchange-heads.js"; import { Entry, Log } from "@dao-xyz/peerbit-log"; import { serialize, deserialize, BorshError, BinaryReader, BinaryWriter, } from "@dao-xyz/borsh"; import { TransportMessage } from "./message.js"; import { AccessError, Ed25519Keypair, MaybeEncrypted, Ed25519PublicKey, sha256, } from "@dao-xyz/peerbit-crypto"; import { FastKeychain } from "./encryption.js"; import { MaybeSigned } from "@dao-xyz/peerbit-crypto"; import { Program, Address } from "@dao-xyz/peerbit-program"; import PQueue from "p-queue"; import { logger as loggerFn } from "@dao-xyz/peerbit-logger"; import { DirectSub, waitForSubscribers, } from "@dao-xyz/libp2p-direct-sub"; import sodium from "libsodium-wrappers"; import path from "path-browserify"; import { TimeoutError, waitFor } from "@dao-xyz/peerbit-time"; import "@libp2p/peer-id"; import { createLibp2pExtended } from "@dao-xyz/peerbit-libp2p"; import { OBSERVER_TYPE_VARIANT, Replicator, REPLICATOR_TYPE_VARIANT, SubscriptionType, } from "@dao-xyz/peerbit-program"; import { startsWith } from "@dao-xyz/uint8arrays"; import { DirectBlock } from "@dao-xyz/libp2p-direct-block"; import { LevelDatastore } from "datastore-level"; export const logger = loggerFn({ module: "peer" }); const MIN_REPLICAS = 2; const isLibp2pInstance = (libp2p) => !!libp2p.getMultiaddrs; const groupByGid = async (entries) => { const groupByGid = new Map(); for (const head of entries) { const gid = await (head instanceof Entry ? head.getGid() : head.entry.getGid()); let value = groupByGid.get(gid); if (!value) { value = []; groupByGid.set(gid, value); } value.push(head); } return groupByGid; }; const createLevel = (path) => { return path ? new Level(path, { valueEncoding: "view" }) : new MemoryLevel({ valueEncoding: "view" }); }; const createCache = async (directory, options) => { const cache = await new LazyLevel(createLevel(directory)); // "Wake up" the caches if they need it if (cache) await cache.open(); if (options?.reset) { await cache._store.clear(); } return cache; }; const createSubCache = async (from, name, options) => { const cache = await new LazyLevel(from._store.sublevel(name)); // "Wake up" the caches if they need it if (cache) await cache.open(); if (options?.reset) { await cache._store.clear(); } return cache; }; export class Peerbit { _libp2p; directory; _minReplicas; /// program address => Program metadata programs; limitSigning; logs = new Map(); _sortedPeersCache = new Map(); _gidPeersHistory = new Map(); _openProgramQueue; _disconnected = false; _disconnecting = false; _refreshInterval; _lastSubscriptionMessageId = 0; _cache; _libp2pExternal = false; // Libp2p peerid in Identity form _identityHash; _identity; _keychain; // Keychain + Caching + X25519 keys constructor(libp2p, options) { if (libp2p == null) { throw new Error("Libp2p required"); } this._libp2p = libp2p; if (this.libp2p.peerId.type !== "Ed25519") { throw new Error("Unsupported id type, expecting Ed25519 but got " + this.libp2p.peerId.type); } if (this.libp2p.peerId.type !== "Ed25519") { throw new Error("Only Ed25519 peerIds are supported"); } this._identity = options.identity; this._keychain = options.keychain; this._identityHash = this._identity.publicKey.hashcode(); this.directory = options.directory; this.programs = new Map(); this._minReplicas = options.minReplicas || MIN_REPLICAS; this.limitSigning = options.limitSigning || false; this._cache = options.cache; this._libp2pExternal = options.libp2pExternal; this._openProgramQueue = new PQueue({ concurrency: 1 }); this.libp2p.services.pubsub.addEventListener("data", this._onMessage.bind(this)); this.libp2p.services.pubsub.addEventListener("subscribe", this._onSubscription.bind(this)); this.libp2p.services.pubsub.addEventListener("unsubscribe", this._onUnsubscription.bind(this)); } static async create(options = {}) { await sodium.ready; // Some of the modules depends on sodium to be readyy let libp2pExtended = options.libp2p; const blocksDirectory = options.directory != null ? path.join(options.directory, "/blocks").toString() : undefined; let libp2pExternal = false; const datastore = options.directory != null ? new LevelDatastore(path.join(options.directory, "/libp2p").toString()) : undefined; if (datastore) { await datastore.open(); } if (!libp2pExtended) { libp2pExtended = await createLibp2pExtended({ services: { blocks: (c) => new DirectBlock(c, { directory: blocksDirectory }), pubsub: (c) => new DirectSub(c), }, // If directory is passed, we store keys within that directory, else we will use memory datastore (which is the default behaviour) datastore, }); } else { if (isLibp2pInstance(libp2pExtended)) { libp2pExternal = true; // libp2p was created outside } else { const extendedOptions = libp2pExtended; libp2pExtended = await createLibp2pExtended({ ...extendedOptions, services: { blocks: (c) => new DirectBlock(c, { directory: blocksDirectory }), pubsub: (c) => new DirectSub(c), ...extendedOptions?.services, }, datastore, }); } } if (datastore) { const stopFn = libp2pExtended.stop.bind(libp2pExtended); libp2pExtended.stop = async () => { await stopFn(); await datastore?.close(); }; } if (!libp2pExtended.isStarted()) { await libp2pExtended.start(); } if (libp2pExtended.peerId.type !== "Ed25519") { throw new Error("Unsupported id type, expecting Ed25519 but got " + libp2pExtended.peerId.type); } const directory = options.directory; const cache = options.cache || (await createCache(directory ? path.join(directory, "/cache") : undefined)); const identity = Ed25519Keypair.fromPeerId(libp2pExtended.peerId); const peer = new Peerbit(libp2pExtended, { directory, cache, libp2pExternal, limitSigning: options.limitSigning, minReplicas: options.minReplicas, refreshIntreval: options.refreshIntreval, identity, keychain: await FastKeychain.create(identity, libp2pExtended.keychain), }); return peer; } get libp2p() { return this._libp2p; } get cache() { return this._cache; } get encryption() { return this._keychain; } get disconnected() { return this._disconnected; } get disconnecting() { return this._disconnecting; } get identityHash() { return this._identityHash; } get identity() { return this._identity; } async importKeypair(keypair) { return this._keychain.importKeypair(keypair); } async exportKeypair(publicKey) { return this._keychain.exportKeypair(publicKey); } /** * Dial a peer with an Ed25519 peerId */ async dial(address) { const maddress = typeof address == "string" ? multiaddr(address) : address instanceof Peerbit ? address.libp2p.getMultiaddrs() : address; const connection = await this.libp2p.dial(maddress); const publicKey = Ed25519PublicKey.fromPeerId(connection.remotePeer); // TODO, do this as a promise instead using the onPeerConnected vents in pubsub and blocks return waitFor(() => this.libp2p.services.pubsub.peers.has(publicKey.hashcode()) && this.libp2p.services.blocks.peers.has(publicKey.hashcode())); } async stop() { this._disconnecting = true; // Close a direct connection and remove it from internal state this._refreshInterval && clearInterval(this._refreshInterval); // Close all open databases await Promise.all([...this.programs.values()].map((program) => program.program.close())); await this._cache.close(); // Close libp2p (after above) if (!this._libp2pExternal) { // only close it if we created it await this.libp2p.stop(); } // Remove all databases from the state this.programs = new Map(); this._disconnecting = false; this._disconnected = true; } // Callback for local writes to the database. We the update to pubsub. onWrite(_program, log, entry) { // TODO Should we also do gidHashhistory update here? createExchangeHeadsMessage(log, [entry], true, this.limitSigning ? undefined : this.identity).then((bytes) => { this.libp2p.services.pubsub.publish(bytes, { topics: [log.idString] }); }); } _maybeOpenStorePromise; // Callback for receiving a message from the network async _onMessage(evt) { const message = evt.detail; /* logger.debug( `${this.id}: Recieved message on topics: ${ message.topics.length > 1 ? "#" + message.topics.length : message.topics[0] } ${message.data.length}` ); */ if (message.topics.find((x) => this.logs.has(x)) == null) { return; // not for me } if (this._disconnecting) { logger.warn("Got message while disconnecting"); return; } if (this._disconnected) { if (!areWeTestingWithJest()) throw new Error("Got message while disconnected"); return; // because these could just be testing sideffects } try { /* const peer = message.type === "signed" ? (message as SignedPubSubMessage).from : undefined; */ const maybeEncryptedMessage = deserialize(message.data, MaybeEncrypted); const decrypted = await maybeEncryptedMessage.decrypt(this.encryption.getAnyKeypair); const signedMessage = decrypted.getValue(MaybeSigned); await signedMessage.verify(); const msg = signedMessage.getValue(TransportMessage); if (msg instanceof ExchangeHeadsMessage) { /** * I have recieved heads from someone else. * I can use them to load associated logs and join/sync them with the data stores I own */ const { logId } = msg; const { heads } = msg; // replication topic === trustedNetwork address const idString = Log.createIdString(logId); logger.debug(`${this.identity.publicKey.hashcode()}: Recieved heads: ${heads.length === 1 ? heads[0].entry.hash : "#" + heads.length}, logId: ${idString}`); if (heads) { const logInfo = this.logs.get(idString); if (!logInfo) { logger.error("Missing log info, which was expected to exist for " + idString); return; } const filteredHeads = []; for (const head of heads) { if (!logInfo.log.has(head.entry.hash)) { head.entry.init({ // we need to init because we perhaps need to decrypt gid encryption: logInfo.log.encryption, encoding: logInfo.log.encoding, }); filteredHeads.push(head); } } let toMerge; if (!logInfo.sync) { toMerge = []; for (const [gid, value] of await groupByGid(filteredHeads)) { if (!(await this.isLeader(logInfo.log, gid, logInfo.minReplicas.value))) { logger.debug(`${this.identity.publicKey.hashcode()}: Dropping heads with gid: ${gid}. Because not leader`); continue; } for (const head of value) { toMerge.push(head); } } } else { toMerge = await Promise.all(filteredHeads.map((x) => logInfo.sync(x.entry))).then((filter) => filteredHeads.filter((v, ix) => filter[ix])); } if (toMerge.length > 0) { await logInfo.log.join(toMerge); /* TODO does this debug affect performance? logger.debug( `${this.id}: Synced ${toMerge.length} heads for '${programAddressObject}/${storeIndex}':\n`, JSON.stringify( toMerge.map((e) => e.entry.hash), null, 2 ) ); */ } } } else { throw new Error("Unexpected message"); } } catch (e) { if (e instanceof BorshError) { logger.trace(`${this.identity.publicKey.hashcode()}: Failed to handle message on topic: ${JSON.stringify(message.topics)} ${message.data.length}: Got message for a different namespace`); return; } if (e instanceof AccessError) { logger.trace(`${this.identity.publicKey.hashcode()}: Failed to handle message on topic: ${JSON.stringify(message.topics)} ${message.data.length}: Got message I could not decrypt`); return; } logger.error(e); } } async _onUnsubscription(evt) { logger.debug(`Peer disconnected '${evt.detail.from.hashcode()}' from '${JSON.stringify(evt.detail.unsubscriptions.map((x) => x.topic))}'`); return this.handleSubscriptionChange(evt.detail.from.hashcode(), evt.detail.unsubscriptions, false); } async _onSubscription(evt) { logger.debug(`New peer '${evt.detail.from.hashcode()}' connected to '${JSON.stringify(evt.detail.subscriptions.map((x) => x.topic))}'`); return this.handleSubscriptionChange(evt.detail.from.hashcode(), evt.detail.subscriptions, true); } modifySortedSubscriptionCache(topic, subscribed, fromHash) { const sortedPeer = this._sortedPeersCache.get(topic); if (sortedPeer) { const code = fromHash; if (subscribed) { // TODO use Set + list for fast lookup if (!sortedPeer.find((x) => x === code)) { sortedPeer.push(code); sortedPeer.sort((a, b) => a.localeCompare(b)); } } else { const deleteIndex = sortedPeer.findIndex((x) => x === code); sortedPeer.splice(deleteIndex, 1); } } else if (subscribed) { this._sortedPeersCache.set(topic, [fromHash]); } } async handleSubscriptionChange(fromHash, changes, subscribed) { for (const c of changes) { if (!c.data || !startsWith(c.data, REPLICATOR_TYPE_VARIANT)) { return; } this._lastSubscriptionMessageId += 1; this.modifySortedSubscriptionCache(c.topic, subscribed, fromHash); } for (const subscription of changes) { if (subscription.data) { try { const type = deserialize(subscription.data, SubscriptionType); if (type instanceof Replicator) { const p = this.logs.get(subscription.topic); if (p) { await this.replicationReorganization([p.log.idString]); } } } catch (error) { logger.warn("Recieved subscription with invalid data on topic: " + subscription.topic + ". Error: " + error?.message); } } } } /** * When a peers join the networkk and want to participate the leaders for particular log subgraphs might change, hence some might start replicating, might some stop * This method will go through my owned entries, and see whether I should share them with a new leader, and/or I should stop care about specific entries * @param channel */ async replicationReorganization(changedLogs) { let changed = false; for (const logId of changedLogs) { const logInfo = this.logs.get(logId); if (!logInfo || logInfo.log.closed) { continue; } const heads = await logInfo.log.getHeads(); const groupedByGid = await groupByGid(heads); let storeChanged = false; for (const [gid, entries] of groupedByGid) { const toSend = new Map(); const newPeers = []; if (entries.length === 0) { continue; // TODO maybe close store? } const oldPeersSet = this._gidPeersHistory.get(gid); const currentPeers = await this.findLeaders(logInfo.log, gid, logInfo.minReplicas.value); for (const currentPeer of currentPeers) { if (!oldPeersSet?.has(currentPeer) && currentPeer !== this.identityHash) { storeChanged = true; // second condition means that if the new peer is us, we should not do anything, since we are expecting to recieve heads, not send newPeers.push(currentPeer); // send heads to the new peer // console.log('new gid for peer', newPeers.length, this.id.toString(), newPeer, gid, entries.length, newPeers) try { logger.debug(`${this.identity.publicKey.hashcode()}: Exchange heads ${entries.length === 1 ? entries[0].hash : "#" + entries.length} on rebalance`); for (const entry of entries) { toSend.set(entry.hash, entry); } } catch (error) { if (error instanceof TimeoutError) { logger.error("Missing channel when reorg to peer: " + currentPeer.toString()); continue; } throw error; } } } // We don't need this clause anymore because we got the trim option! if (!currentPeers.find((x) => x === this.identityHash)) { let entriesToDelete = entries.filter((e) => !e.createdLocally); if (logInfo.sync) { // dont delete entries which we wish to keep entriesToDelete = await Promise.all(entriesToDelete.map((x) => logInfo.sync(x))).then((filter) => entriesToDelete.filter((v, ix) => !filter[ix])); } // delete entries since we are not suppose to replicate this anymore // TODO add delay? freeze time? (to ensure resiliance for bad io) if (entriesToDelete.length > 0) { await logInfo.log.remove(entriesToDelete, { recursively: true, }); } // TODO if length === 0 maybe close store? } this._gidPeersHistory.set(gid, new Set(currentPeers)); if (toSend.size === 0) { continue; } const bytes = await createExchangeHeadsMessage(logInfo.log, [...toSend.values()], // TODO send to peers directly true, this.limitSigning ? undefined : this.identity); // TODO perhaps send less messages to more recievers for performance reasons? await this._libp2p.services.pubsub.publish(bytes, { to: newPeers, strict: true, topics: [logInfo.log.idString], }); } if (storeChanged) { await logInfo.log.trim(); // because for entries createdLocally,we can have trim options that still allow us to delete them } changed = storeChanged || changed; } return changed; } /* TODO put this on the program level getCanTrust(address: Address): CanTrust | undefined { const p = this.programs.get(address.toString())?.program; if (p) { const ct = this.programs.get(address.toString()) ?.program as any as CanTrust; if (ct.isTrusted !== undefined) { return ct; } } return; } */ // Callback when a store was closed async _onClose(program, log) { // TODO Can we really close a this.programs, either we close all stores in the replication topic or none const programAddress = program.address?.toString(); logger.debug(`Close ${programAddress}/${log.idString}`); const logid = log.idString; const lookup = this.logs.get(logid); if (lookup) { lookup.open -= 1; if (lookup.open === 0) { this.logs.delete(logid); await this.unsubscribeToProgram(log); // TODO unsubscribe with 1 role but maybe have another role left? } } } async _onProgamClose(program, programCache) { await programCache.close(); const programAddress = program.address?.toString(); if (programAddress) { this.programs.delete(programAddress); } } addProgram(program) { const programAddress = program.address?.toString(); if (!programAddress) { throw new Error("Missing program address"); } const existingProgramAndStores = this.programs.get(programAddress); if (!!existingProgramAndStores && existingProgramAndStores.program !== program) { // second condition only makes this throw error if we are to add a new instance with the same address throw new Error(`Program at ${programAddress} is already created`); } const p = { program, openCounter: 1, replicators: new Set(), }; this.programs.set(programAddress, p); return p; } /* getReplicators(log: Log<any>): string[] | undefined { let replicators = this.libp2p.services.pubsub.getSubscribersWithData( log.idString, REPLICATOR_TYPE_VARIANT, { prefix: true } ); const iAmReplicating = this._logsById.get(log.idString)?.log.replication.replicating; // TODO add conditional whether this represents a network (I am not replicating if I am not trusted (pointless)) if (iAmReplicating) { replicators = replicators || []; replicators.push(this.idKeyHash.toString()); } return replicators; } */ getReplicatorsSorted(log) { return this._sortedPeersCache.get(log.idString); } getObservers(address) { return this.libp2p.services.pubsub.getSubscribersWithData(address.toString(), OBSERVER_TYPE_VARIANT, { prefix: true }); } async isLeader(log, slot, numberOfLeaders) { const isLeader = (await this.findLeaders(log, slot, numberOfLeaders)).find((l) => l === this.identityHash); return !!isLeader; } async findLeaders(log, subject, numberOfLeaders) { // For a fixed set or members, the choosen leaders will always be the same (address invariant) // This allows for that same content is always chosen to be distributed to same peers, to remove unecessary copies const peers = this.getReplicatorsSorted(log) || []; // Assumption: Network specification is accurate // Replication topic is not an address we assume that the network allows all participants /* TODO put this on the program level const network = this.getCanTrust(address); let peers: string[]; if (network) { const isTrusted = (peer: string) => network ? network.isTrusted( peer // TODO improve perf, caching etc? ) : true; peers = await Promise.all(peersPreFilter.map(isTrusted)).then((results) => peersPreFilter.filter((_v, index) => results[index]) ); } else { peers = peersPreFilter; } */ if (peers.length === 0) { return []; } numberOfLeaders = Math.min(numberOfLeaders, peers.length); // Convert this thing we wan't to distribute to 8 bytes so we get can convert it into a u64 // modulus into an index const utf8writer = new BinaryWriter(); utf8writer.string(subject.toString()); const seed = await sha256(utf8writer.finalize()); // convert hash of slot to a number const seedNumber = new BinaryReader(seed.subarray(seed.length - 8, seed.length)).u64(); const startIndex = Number(seedNumber % BigInt(peers.length)); // we only step forward 1 step (ignoring that step backward 1 could be 'closer') // This does not matter, we only have to make sure all nodes running the code comes to somewhat the // same conclusion (are running the same leader selection algorithm) const leaders = new Array(numberOfLeaders); for (let i = 0; i < numberOfLeaders; i++) { leaders[i] = peers[(i + startIndex) % peers.length]; } return leaders; } async subscribeToProgram(log, role) { if (this._disconnected || this._disconnecting) { throw new Error("Disconnected"); } if (role instanceof Replicator) { this.modifySortedSubscriptionCache(log.idString, true, this.identityHash); } this.libp2p.services.pubsub.subscribe(log.idString, { data: serialize(role), }); return this.libp2p.services.pubsub.requestSubscribers(log.idString); // get up to date with who are subscribing to this topic } async unsubscribeToProgram(id) { if (this._disconnected) { throw new Error("Disconnected"); } this._sortedPeersCache.delete(id.idString); await this.libp2p.services.pubsub.unsubscribe(id.idString); } hasSubscribedToTopic(topic) { return this.programs.has(topic); } /** * Default behaviour of a store is only to accept heads that are forks (new roots) with some probability * and to replicate heads (and updates) which is requested by another peer * @param store * @param options * @returns */ async open(storeOrAddress, options = {}) { if (this._disconnected || this._disconnecting) { throw new Error("Can not open a store while disconnected"); } const fn = async () => { // TODO add locks for store lifecycle, e.g. what happens if we try to open and close a store at the same time? if (typeof storeOrAddress === "string" || storeOrAddress instanceof Address) { storeOrAddress = storeOrAddress instanceof Address ? storeOrAddress : Address.parse(storeOrAddress); } let program = storeOrAddress; let existing = false; if (storeOrAddress instanceof Address || typeof storeOrAddress === "string") { try { const fromExisting = this.programs?.get(storeOrAddress.toString()) ?.program; if (fromExisting) { program = fromExisting; existing = true; } else { program = (await Program.load(this._libp2p.services.blocks, storeOrAddress, options)); // TODO fix typings if (program instanceof Program === false) { throw new Error(`Failed to open program because program is of type ${program?.constructor.name} and not ${Program.name}`); } } } catch (error) { logger.error("Failed to load store with address: " + storeOrAddress.toString()); throw error; } } if (!program.address && !existing) { await program.save(this._libp2p.services.blocks); } const programAddress = program.address.toString(); if (programAddress) { const existingProgram = this.programs?.get(programAddress); if (existingProgram) { existingProgram.openCounter += 1; return existingProgram; } } logger.debug(`Open database '${program.constructor.name}`); const role = options.role || new Replicator(); const minReplicas = options.minReplicas != null ? typeof options.minReplicas === "number" ? new AbsolutMinReplicas(options.minReplicas) : options.minReplicas : new AbsolutMinReplicas(this._minReplicas); let programCache = undefined; const resolveMinReplicas = (log) => this.logs.get(log.idString).minReplicas.value; await program.init(this.libp2p, { onClose: async () => { return this._onProgamClose(program, programCache); }, onDrop: () => this._onProgamClose(program, programCache), role, // If the program opens more programs open: (program) => this.open(program, options), onSave: async (address) => { programCache = await createSubCache(this._cache, address.toString(), { reset: options.reset, }); }, encryption: this.encryption, waitFor: async (other) => { await Promise.all(program.logs.map((x) => waitForSubscribers(this.libp2p, other, x.idString))); }, log: (log) => { const cfg = { encryption: this.encryption, trim: options.trim && { ...options.trim, filter: { canTrim: async (gid) => !(await this.isLeader(log, gid, resolveMinReplicas(log))), cacheId: () => this._lastSubscriptionMessageId, }, }, cache: async (name) => { return createSubCache(programCache, // TODO types path.join("log", name)); }, onClose: async () => { await this._onClose(program, log); return options.log?.onClose?.(log); }, onDrop: async () => { await this._onClose(program, log); return options.log?.onClose?.(log); }, replication: { replicators: () => { // TODO Optimize this so we don't have to recreate the array all the time! const minReplicas = resolveMinReplicas(log); const replicators = this.getReplicatorsSorted(log); if (!replicators) { return []; // No subscribers and we are not replicating } const numberOfGroups = Math.min(Math.ceil(replicators.length / minReplicas)); const groups = new Array(numberOfGroups); for (let i = 0; i < groups.length; i++) { groups[i] = []; } for (let i = 0; i < replicators.length; i++) { groups[i % numberOfGroups].push(replicators[i]); } return groups; }, replicator: (gid) => this.isLeader(log, gid, resolveMinReplicas(log)), }, onOpen: async () => { const logid = log.idString; const lookup = this.logs.get(logid); if (lookup) { lookup.open += 1; } else { await this.subscribeToProgram(log, role); this.logs.set(logid, { log, open: 1, sync: options.sync, minReplicas, }); } }, onWrite: async (entry) => { await this.onWrite(program, log, entry); return options.log?.onWrite?.(log, entry); }, onChange: async (change) => { return options?.log?.onChange?.(log, change); }, }; return cfg; }, }); return this.addProgram(program); }; const openStore = await this._openProgramQueue.add(fn); if (!openStore?.program.address) { throw new Error("Unexpected"); } return openStore.program; } } const areWeTestingWithJest = () => { return process.env.JEST_WORKER_ID !== undefined; }; //# sourceMappingURL=peer.js.map