@comapeo/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +9 -0
- package/README.md +31 -0
- package/dist/blob-api.d.ts +92 -0
- package/dist/blob-api.d.ts.map +1 -0
- package/dist/blob-store/index.d.ts +163 -0
- package/dist/blob-store/index.d.ts.map +1 -0
- package/dist/blob-store/live-download.d.ts +107 -0
- package/dist/blob-store/live-download.d.ts.map +1 -0
- package/dist/config-import.d.ts +74 -0
- package/dist/config-import.d.ts.map +1 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/core-manager/bitfield-rle.d.ts +25 -0
- package/dist/core-manager/bitfield-rle.d.ts.map +1 -0
- package/dist/core-manager/core-index.d.ts +56 -0
- package/dist/core-manager/core-index.d.ts.map +1 -0
- package/dist/core-manager/index.d.ts +125 -0
- package/dist/core-manager/index.d.ts.map +1 -0
- package/dist/core-manager/random-access-file-pool.d.ts +17 -0
- package/dist/core-manager/random-access-file-pool.d.ts.map +1 -0
- package/dist/core-manager/remote-bitfield.d.ts +146 -0
- package/dist/core-manager/remote-bitfield.d.ts.map +1 -0
- package/dist/core-ownership.d.ts +112 -0
- package/dist/core-ownership.d.ts.map +1 -0
- package/dist/datastore/index.d.ts +91 -0
- package/dist/datastore/index.d.ts.map +1 -0
- package/dist/datatype/index.d.ts +108 -0
- package/dist/discovery/local-discovery.d.ts +64 -0
- package/dist/discovery/local-discovery.d.ts.map +1 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/fastify-controller.d.ts +27 -0
- package/dist/fastify-controller.d.ts.map +1 -0
- package/dist/fastify-plugins/blobs.d.ts +6 -0
- package/dist/fastify-plugins/blobs.d.ts.map +1 -0
- package/dist/fastify-plugins/constants.d.ts +3 -0
- package/dist/fastify-plugins/constants.d.ts.map +1 -0
- package/dist/fastify-plugins/icons.d.ts +6 -0
- package/dist/fastify-plugins/icons.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/index.d.ts +11 -0
- package/dist/fastify-plugins/maps/index.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +12 -0
- package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/static-maps.d.ts +11 -0
- package/dist/fastify-plugins/maps/static-maps.d.ts.map +1 -0
- package/dist/fastify-plugins/utils.d.ts +23 -0
- package/dist/fastify-plugins/utils.d.ts.map +1 -0
- package/dist/generated/extensions.d.ts +44 -0
- package/dist/generated/extensions.d.ts.map +1 -0
- package/dist/generated/keys.d.ts +36 -0
- package/dist/generated/keys.d.ts.map +1 -0
- package/dist/generated/rpc.d.ts +87 -0
- package/dist/generated/rpc.d.ts.map +1 -0
- package/dist/icon-api.d.ts +109 -0
- package/dist/icon-api.d.ts.map +1 -0
- package/dist/index-writer/index.d.ts +51 -0
- package/dist/index-writer/index.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/invite-api.d.ts +70 -0
- package/dist/invite-api.d.ts.map +1 -0
- package/dist/lib/hashmap.d.ts +62 -0
- package/dist/lib/hashmap.d.ts.map +1 -0
- package/dist/lib/hypercore-helpers.d.ts +6 -0
- package/dist/lib/hypercore-helpers.d.ts.map +1 -0
- package/dist/lib/noise-secret-stream-helpers.d.ts +45 -0
- package/dist/lib/noise-secret-stream-helpers.d.ts.map +1 -0
- package/dist/lib/ponyfills.d.ts +10 -0
- package/dist/lib/ponyfills.d.ts.map +1 -0
- package/dist/lib/string.d.ts +2 -0
- package/dist/lib/string.d.ts.map +1 -0
- package/dist/lib/timing-safe-equal.d.ts +15 -0
- package/dist/lib/timing-safe-equal.d.ts.map +1 -0
- package/dist/local-peers.d.ts +151 -0
- package/dist/local-peers.d.ts.map +1 -0
- package/dist/logger.d.ts +32 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/mapeo-manager.d.ts +178 -0
- package/dist/mapeo-manager.d.ts.map +1 -0
- package/dist/mapeo-project.d.ts +3233 -0
- package/dist/mapeo-project.d.ts.map +1 -0
- package/dist/member-api.d.ts +114 -0
- package/dist/member-api.d.ts.map +1 -0
- package/dist/roles.d.ts +157 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/schema/client.d.ts +284 -0
- package/dist/schema/client.d.ts.map +1 -0
- package/dist/schema/project.d.ts +1812 -0
- package/dist/schema/project.d.ts.map +1 -0
- package/dist/schema/schema-to-drizzle.d.ts +20 -0
- package/dist/schema/schema-to-drizzle.d.ts.map +1 -0
- package/dist/schema/types.d.ts +98 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/schema/utils.d.ts +55 -0
- package/dist/schema/utils.d.ts.map +1 -0
- package/dist/sync/core-sync-state.d.ts +252 -0
- package/dist/sync/core-sync-state.d.ts.map +1 -0
- package/dist/sync/namespace-sync-state.d.ts +47 -0
- package/dist/sync/namespace-sync-state.d.ts.map +1 -0
- package/dist/sync/peer-sync-controller.d.ts +44 -0
- package/dist/sync/peer-sync-controller.d.ts.map +1 -0
- package/dist/sync/sync-api.d.ts +158 -0
- package/dist/sync/sync-api.d.ts.map +1 -0
- package/dist/sync/sync-state.d.ts +40 -0
- package/dist/sync/sync-state.d.ts.map +1 -0
- package/dist/translation-api.d.ts +288 -0
- package/dist/translation-api.d.ts.map +1 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +115 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils_types.d.ts +14 -0
- package/drizzle/client/0000_bumpy_carnage.sql +33 -0
- package/drizzle/client/meta/0000_snapshot.json +199 -0
- package/drizzle/client/meta/_journal.json +13 -0
- package/drizzle/project/0000_spooky_lady_ursula.sql +192 -0
- package/drizzle/project/meta/0000_snapshot.json +1137 -0
- package/drizzle/project/meta/_journal.json +13 -0
- package/package.json +202 -0
- package/src/blob-api.js +139 -0
- package/src/blob-store/index.js +325 -0
- package/src/blob-store/live-download.js +373 -0
- package/src/config-import.js +604 -0
- package/src/constants.js +34 -0
- package/src/core-manager/bitfield-rle.js +235 -0
- package/src/core-manager/core-index.js +87 -0
- package/src/core-manager/index.js +504 -0
- package/src/core-manager/random-access-file-pool.js +30 -0
- package/src/core-manager/remote-bitfield.js +416 -0
- package/src/core-ownership.js +235 -0
- package/src/datastore/README.md +46 -0
- package/src/datastore/index.js +234 -0
- package/src/datatype/README.md +33 -0
- package/src/datatype/index.d.ts +108 -0
- package/src/datatype/index.js +358 -0
- package/src/discovery/local-discovery.js +303 -0
- package/src/errors.js +5 -0
- package/src/fastify-controller.js +84 -0
- package/src/fastify-plugins/blobs.js +139 -0
- package/src/fastify-plugins/constants.js +5 -0
- package/src/fastify-plugins/icons.js +158 -0
- package/src/fastify-plugins/maps/index.js +173 -0
- package/src/fastify-plugins/maps/offline-fallback-map.js +114 -0
- package/src/fastify-plugins/maps/static-maps.js +271 -0
- package/src/fastify-plugins/utils.js +52 -0
- package/src/generated/README.md +3 -0
- package/src/generated/extensions.d.ts +44 -0
- package/src/generated/extensions.js +196 -0
- package/src/generated/extensions.ts +237 -0
- package/src/generated/keys.d.ts +36 -0
- package/src/generated/keys.js +148 -0
- package/src/generated/keys.ts +185 -0
- package/src/generated/rpc.d.ts +87 -0
- package/src/generated/rpc.js +389 -0
- package/src/generated/rpc.ts +463 -0
- package/src/icon-api.js +282 -0
- package/src/index-writer/README.md +38 -0
- package/src/index-writer/index.js +124 -0
- package/src/index.js +16 -0
- package/src/invite-api.js +450 -0
- package/src/lib/hashmap.js +91 -0
- package/src/lib/hypercore-helpers.js +18 -0
- package/src/lib/noise-secret-stream-helpers.js +37 -0
- package/src/lib/ponyfills.js +25 -0
- package/src/lib/string.js +7 -0
- package/src/lib/timing-safe-equal.js +34 -0
- package/src/local-peers.js +737 -0
- package/src/logger.js +99 -0
- package/src/mapeo-manager.js +914 -0
- package/src/mapeo-project.js +980 -0
- package/src/member-api.js +319 -0
- package/src/roles.js +412 -0
- package/src/schema/client.js +55 -0
- package/src/schema/project.js +44 -0
- package/src/schema/schema-to-drizzle.js +118 -0
- package/src/schema/types.ts +153 -0
- package/src/schema/utils.js +51 -0
- package/src/sync/core-sync-state.js +440 -0
- package/src/sync/namespace-sync-state.js +193 -0
- package/src/sync/peer-sync-controller.js +332 -0
- package/src/sync/sync-api.js +588 -0
- package/src/sync/sync-state.js +63 -0
- package/src/translation-api.js +141 -0
- package/src/types.ts +149 -0
- package/src/utils.js +210 -0
- package/src/utils_types.d.ts +14 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import { TypedEmitter } from 'tiny-typed-emitter'
|
|
2
|
+
import Protomux from 'protomux'
|
|
3
|
+
import { assert, ExhaustivenessError, keyToId, noop } from './utils.js'
|
|
4
|
+
import { isBlank } from './lib/string.js'
|
|
5
|
+
import cenc from 'compact-encoding'
|
|
6
|
+
import {
|
|
7
|
+
DeviceInfo,
|
|
8
|
+
Invite,
|
|
9
|
+
InviteCancel,
|
|
10
|
+
InviteResponse,
|
|
11
|
+
ProjectJoinDetails,
|
|
12
|
+
} from './generated/rpc.js'
|
|
13
|
+
import pDefer from 'p-defer'
|
|
14
|
+
import { Logger } from './logger.js'
|
|
15
|
+
import pTimeout, { TimeoutError } from 'p-timeout'
|
|
16
|
+
/** @import { OpenedNoiseStream } from './lib/noise-secret-stream-helpers.js' */
|
|
17
|
+
|
|
18
|
+
// Unique identifier for the mapeo rpc protocol
|
|
19
|
+
const PROTOCOL_NAME = 'mapeo/rpc'
|
|
20
|
+
// Timeout in milliseconds to wait for a peer to connect when trying to send a message
|
|
21
|
+
const SEND_TIMEOUT = 1000
|
|
22
|
+
// Timeout in milliseconds to wait for peer deduplication
|
|
23
|
+
const DEDUPE_TIMEOUT = 1000
|
|
24
|
+
|
|
25
|
+
// Protomux message types depend on the order that messages are added to a
|
|
26
|
+
// channel (this needs to remain consistent). To avoid breaking changes, the
|
|
27
|
+
// types here should not change.
|
|
28
|
+
/** @satisfies {{ [k in keyof typeof import('./generated/rpc.js')]?: number }} */
|
|
29
|
+
const MESSAGE_TYPES = {
|
|
30
|
+
Invite: 0,
|
|
31
|
+
InviteCancel: 1,
|
|
32
|
+
InviteResponse: 2,
|
|
33
|
+
ProjectJoinDetails: 3,
|
|
34
|
+
DeviceInfo: 4,
|
|
35
|
+
}
|
|
36
|
+
const MESSAGES_MAX_ID = Math.max.apply(null, [...Object.values(MESSAGE_TYPES)])
|
|
37
|
+
|
|
38
|
+
export const kTestOnlySendRawInvite = Symbol('testOnlySendRawInvite')
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {object} PeerInfoBase
|
|
42
|
+
* @property {string} deviceId
|
|
43
|
+
* @property {string | undefined} name
|
|
44
|
+
* @property {import('./generated/rpc.js').DeviceInfo['deviceType']} deviceType
|
|
45
|
+
*/
|
|
46
|
+
/** @typedef {PeerInfoBase & { status: 'connecting' }} PeerInfoConnecting */
|
|
47
|
+
/** @typedef {PeerInfoBase & { status: 'connected', connectedAt: number, protomux: Protomux<import('@hyperswarm/secret-stream')> }} PeerInfoConnected */
|
|
48
|
+
/** @typedef {PeerInfoBase & { status: 'disconnected', disconnectedAt: number }} PeerInfoDisconnected */
|
|
49
|
+
|
|
50
|
+
/** @typedef {PeerInfoConnecting | PeerInfoConnected | PeerInfoDisconnected} PeerInfoInternal */
|
|
51
|
+
/** @typedef {PeerInfoConnected | PeerInfoDisconnected} PeerInfo */
|
|
52
|
+
/** @typedef {PeerInfoInternal['status']} PeerState */
|
|
53
|
+
|
|
54
|
+
class Peer {
|
|
55
|
+
/** @type {PeerState} */
|
|
56
|
+
#state = 'connecting'
|
|
57
|
+
#deviceId
|
|
58
|
+
#channel
|
|
59
|
+
#connected
|
|
60
|
+
/** @type {string | undefined} */
|
|
61
|
+
#name
|
|
62
|
+
/** @type {DeviceInfo['deviceType']} */
|
|
63
|
+
#deviceType
|
|
64
|
+
#connectedAt = 0
|
|
65
|
+
#disconnectedAt = 0
|
|
66
|
+
#protomux
|
|
67
|
+
#log
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {object} options
|
|
71
|
+
* @param {string} options.peerId
|
|
72
|
+
* @param {ReturnType<typeof Protomux.prototype.createChannel>} options.channel
|
|
73
|
+
* @param {Protomux<any>} options.protomux
|
|
74
|
+
* @param {Logger} [options.logger]
|
|
75
|
+
*/
|
|
76
|
+
constructor({ peerId, channel, protomux, logger }) {
|
|
77
|
+
this.#deviceId = peerId
|
|
78
|
+
this.#channel = channel
|
|
79
|
+
this.#protomux = protomux
|
|
80
|
+
this.#connected = pDefer()
|
|
81
|
+
// Avoid unhandled rejections
|
|
82
|
+
this.#connected.promise.catch(noop)
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} formatter
|
|
85
|
+
* @param {unknown[]} args
|
|
86
|
+
* @returns {void}
|
|
87
|
+
*/
|
|
88
|
+
this.#log = (formatter, ...args) => {
|
|
89
|
+
const log = Logger.create('peer', logger).log
|
|
90
|
+
return log.apply(null, [`[%S] ${formatter}`, peerId, ...args])
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** @returns {PeerInfoInternal} */
|
|
94
|
+
get info() {
|
|
95
|
+
switch (this.#state) {
|
|
96
|
+
case 'connecting':
|
|
97
|
+
return {
|
|
98
|
+
status: this.#state,
|
|
99
|
+
deviceId: this.#deviceId,
|
|
100
|
+
name: this.#name,
|
|
101
|
+
deviceType: this.#deviceType,
|
|
102
|
+
}
|
|
103
|
+
case 'connected':
|
|
104
|
+
return {
|
|
105
|
+
status: this.#state,
|
|
106
|
+
deviceId: this.#deviceId,
|
|
107
|
+
name: this.#name,
|
|
108
|
+
deviceType: this.#deviceType,
|
|
109
|
+
connectedAt: this.#connectedAt,
|
|
110
|
+
protomux: this.#protomux,
|
|
111
|
+
}
|
|
112
|
+
case 'disconnected':
|
|
113
|
+
return {
|
|
114
|
+
status: this.#state,
|
|
115
|
+
deviceId: this.#deviceId,
|
|
116
|
+
name: this.#name,
|
|
117
|
+
deviceType: this.#deviceType,
|
|
118
|
+
disconnectedAt: this.#disconnectedAt,
|
|
119
|
+
}
|
|
120
|
+
/* c8 ignore next 2 */
|
|
121
|
+
default:
|
|
122
|
+
throw new ExhaustivenessError(this.#state)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A promise that resolves when the peer connects, or rejects if it
|
|
127
|
+
* fails to connect
|
|
128
|
+
*/
|
|
129
|
+
get connected() {
|
|
130
|
+
return this.#connected.promise
|
|
131
|
+
}
|
|
132
|
+
get protomux() {
|
|
133
|
+
return this.#protomux
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
connect() {
|
|
137
|
+
/* c8 ignore next 4 */
|
|
138
|
+
if (this.#state !== 'connecting') {
|
|
139
|
+
this.#log('ERROR: tried to connect but state was %s', this.#state)
|
|
140
|
+
return // TODO: report error - this should not happen
|
|
141
|
+
}
|
|
142
|
+
this.#state = 'connected'
|
|
143
|
+
this.#connectedAt = Date.now()
|
|
144
|
+
this.#connected.resolve()
|
|
145
|
+
this.#log('connected')
|
|
146
|
+
}
|
|
147
|
+
disconnect() {
|
|
148
|
+
// @ts-ignore - easier to ignore this than handle this for TS - avoids holding a reference to old Protomux instances
|
|
149
|
+
this.#protomux = undefined
|
|
150
|
+
/* c8 ignore next 4 */
|
|
151
|
+
if (this.#state === 'disconnected') {
|
|
152
|
+
this.#log('ERROR: tried to disconnect but was already disconnected')
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
this.#state = 'disconnected'
|
|
156
|
+
this.#disconnectedAt = Date.now()
|
|
157
|
+
// This promise should have already resolved, but if the peer never connected then we reject here
|
|
158
|
+
this.#connected.reject(new PeerFailedConnectionError())
|
|
159
|
+
this.#log('disconnected')
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* @param {Buffer} buf
|
|
163
|
+
*/
|
|
164
|
+
[kTestOnlySendRawInvite](buf) {
|
|
165
|
+
this.#assertConnected()
|
|
166
|
+
const messageType = MESSAGE_TYPES.Invite
|
|
167
|
+
this.#channel.messages[messageType].send(buf)
|
|
168
|
+
}
|
|
169
|
+
/** @param {Invite} invite */
|
|
170
|
+
sendInvite(invite) {
|
|
171
|
+
this.#assertConnected()
|
|
172
|
+
const buf = Buffer.from(Invite.encode(invite).finish())
|
|
173
|
+
const messageType = MESSAGE_TYPES.Invite
|
|
174
|
+
this.#channel.messages[messageType].send(buf)
|
|
175
|
+
this.#log('sent invite %h', invite.inviteId)
|
|
176
|
+
}
|
|
177
|
+
/** @param {InviteCancel} inviteCancel */
|
|
178
|
+
sendInviteCancel(inviteCancel) {
|
|
179
|
+
this.#assertConnected()
|
|
180
|
+
const buf = Buffer.from(InviteCancel.encode(inviteCancel).finish())
|
|
181
|
+
const messageType = MESSAGE_TYPES.InviteCancel
|
|
182
|
+
this.#channel.messages[messageType].send(buf)
|
|
183
|
+
this.#log('sent invite cancel %h', inviteCancel.inviteId)
|
|
184
|
+
}
|
|
185
|
+
/** @param {InviteResponse} response */
|
|
186
|
+
sendInviteResponse(response) {
|
|
187
|
+
this.#assertConnected()
|
|
188
|
+
const buf = Buffer.from(InviteResponse.encode(response).finish())
|
|
189
|
+
const messageType = MESSAGE_TYPES.InviteResponse
|
|
190
|
+
this.#channel.messages[messageType].send(buf)
|
|
191
|
+
this.#log('sent response for %h: %s', response.inviteId, response.decision)
|
|
192
|
+
}
|
|
193
|
+
/** @param {ProjectJoinDetails} details */
|
|
194
|
+
sendProjectJoinDetails(details) {
|
|
195
|
+
this.#assertConnected()
|
|
196
|
+
const buf = Buffer.from(ProjectJoinDetails.encode(details).finish())
|
|
197
|
+
const messageType = MESSAGE_TYPES.ProjectJoinDetails
|
|
198
|
+
this.#channel.messages[messageType].send(buf)
|
|
199
|
+
this.#log('sent project join details for %h', details.projectKey)
|
|
200
|
+
}
|
|
201
|
+
/** @param {DeviceInfo} deviceInfo */
|
|
202
|
+
sendDeviceInfo(deviceInfo) {
|
|
203
|
+
const buf = Buffer.from(DeviceInfo.encode(deviceInfo).finish())
|
|
204
|
+
const messageType = MESSAGE_TYPES.DeviceInfo
|
|
205
|
+
this.#channel.messages[messageType].send(buf)
|
|
206
|
+
this.#log('sent deviceInfo %o', deviceInfo)
|
|
207
|
+
}
|
|
208
|
+
/** @param {DeviceInfo} deviceInfo */
|
|
209
|
+
receiveDeviceInfo(deviceInfo) {
|
|
210
|
+
this.#name = deviceInfo.name
|
|
211
|
+
this.#deviceType = deviceInfo.deviceType
|
|
212
|
+
this.#log('received deviceInfo %o', deviceInfo)
|
|
213
|
+
}
|
|
214
|
+
#assertConnected() {
|
|
215
|
+
if (this.#state === 'connected' && !this.#channel.closed) return
|
|
216
|
+
/* c8 ignore next */
|
|
217
|
+
throw new PeerDisconnectedError() // TODO: report error - this should not happen
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @typedef {object} LocalPeersEvents
|
|
223
|
+
* @property {(peers: PeerInfo[]) => void} peers Emitted whenever the connection status of peers changes. An array of peerInfo objects with a peer id and the peer connection status
|
|
224
|
+
* @property {(peer: PeerInfoConnected) => void} peer-add Emitted when a new peer is connected
|
|
225
|
+
* @property {(peerId: string, invite: Invite) => void} invite Emitted when an invite is received
|
|
226
|
+
* @property {(peerId: string, invite: InviteCancel) => void} invite-cancel Emitted when we receive a cancelation for an invite
|
|
227
|
+
* @property {(peerId: string, inviteResponse: InviteResponse) => void} invite-response Emitted when an invite response is received
|
|
228
|
+
* @property {(peerId: string, details: ProjectJoinDetails) => void} got-project-details Emitted when project details are received
|
|
229
|
+
* @property {(discoveryKey: Buffer, protomux: Protomux<import('@hyperswarm/secret-stream')>) => void} discovery-key Emitted when a new hypercore is replicated (by a peer) to a peer protomux instance (passed as the second parameter)
|
|
230
|
+
* @property {(messageType: string, errorMessage?: string) => void} failed-to-handle-message Emitted when we received a message we couldn't handle for some reason. Primarily useful for testing
|
|
231
|
+
*/
|
|
232
|
+
|
|
233
|
+
/** @extends {TypedEmitter<LocalPeersEvents>} */
|
|
234
|
+
export class LocalPeers extends TypedEmitter {
|
|
235
|
+
/** @type {Map<string, Set<Peer>>} */
|
|
236
|
+
#peers = new Map()
|
|
237
|
+
/** @type {Set<Peer>} */
|
|
238
|
+
#lastEmittedPeers = new Set()
|
|
239
|
+
/** @type {Set<Promise<any>>} */
|
|
240
|
+
#opening = new Set()
|
|
241
|
+
|
|
242
|
+
#l
|
|
243
|
+
/** @type {Set<Protomux>} */
|
|
244
|
+
#attached = new Set()
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
*
|
|
248
|
+
* @param {object} [opts]
|
|
249
|
+
* @param {Logger} [opts.logger]
|
|
250
|
+
*/
|
|
251
|
+
constructor({ logger } = {}) {
|
|
252
|
+
super()
|
|
253
|
+
this.#l = Logger.create('localPeers', logger)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
get peers() {
|
|
257
|
+
const connectedPeerInfos = []
|
|
258
|
+
for (const { info } of this.#getPeers()) {
|
|
259
|
+
connectedPeerInfos.push(info)
|
|
260
|
+
}
|
|
261
|
+
return connectedPeerInfos
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @param {string} deviceId
|
|
266
|
+
* @param {Invite} invite
|
|
267
|
+
* @returns {Promise<void>}
|
|
268
|
+
*/
|
|
269
|
+
async sendInvite(deviceId, invite) {
|
|
270
|
+
await this.#waitForPendingConnections()
|
|
271
|
+
const peer = await this.#getPeerByDeviceId(deviceId)
|
|
272
|
+
peer.sendInvite(invite)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* @param {string} deviceId
|
|
277
|
+
* @param {InviteCancel} inviteCancel
|
|
278
|
+
* @returns {Promise<void>}
|
|
279
|
+
*/
|
|
280
|
+
async sendInviteCancel(deviceId, inviteCancel) {
|
|
281
|
+
await this.#waitForPendingConnections()
|
|
282
|
+
const peer = await this.#getPeerByDeviceId(deviceId)
|
|
283
|
+
peer.sendInviteCancel(inviteCancel)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Respond to an invite from a peer
|
|
288
|
+
*
|
|
289
|
+
* @param {string} deviceId id of the peer you want to respond to (publicKey of peer as hex string)
|
|
290
|
+
* @param {InviteResponse} inviteResponse
|
|
291
|
+
*/
|
|
292
|
+
async sendInviteResponse(deviceId, inviteResponse) {
|
|
293
|
+
await this.#waitForPendingConnections()
|
|
294
|
+
const peer = await this.#getPeerByDeviceId(deviceId)
|
|
295
|
+
peer.sendInviteResponse(inviteResponse)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* @param {string} deviceId
|
|
300
|
+
* @param {ProjectJoinDetails} details
|
|
301
|
+
*/
|
|
302
|
+
async sendProjectJoinDetails(deviceId, details) {
|
|
303
|
+
await this.#waitForPendingConnections()
|
|
304
|
+
const peer = await this.#getPeerByDeviceId(deviceId)
|
|
305
|
+
peer.sendProjectJoinDetails(details)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
*
|
|
310
|
+
* @param {string} deviceId id of the peer you want to send to (publicKey of peer as hex string)
|
|
311
|
+
* @param {DeviceInfo} deviceInfo device info to send
|
|
312
|
+
*/
|
|
313
|
+
async sendDeviceInfo(deviceId, deviceInfo) {
|
|
314
|
+
await this.#waitForPendingConnections()
|
|
315
|
+
const peer = await this.#getPeerByDeviceId(deviceId)
|
|
316
|
+
peer.sendDeviceInfo(deviceInfo)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @param {string} deviceId
|
|
321
|
+
* @param {Buffer} buf
|
|
322
|
+
*/
|
|
323
|
+
async [kTestOnlySendRawInvite](deviceId, buf) {
|
|
324
|
+
await this.#waitForPendingConnections()
|
|
325
|
+
const peer = await this.#getPeerByDeviceId(deviceId)
|
|
326
|
+
peer[kTestOnlySendRawInvite](buf)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Connect to a peer over an existing NoiseSecretStream
|
|
331
|
+
*
|
|
332
|
+
* @param {import('./types.js').NoiseStream<any>} stream a NoiseSecretStream from @hyperswarm/secret-stream
|
|
333
|
+
* @returns {import('./types.js').ReplicationStream}
|
|
334
|
+
*/
|
|
335
|
+
connect(stream) {
|
|
336
|
+
const noiseStream = stream.noiseStream
|
|
337
|
+
if (!noiseStream) throw new Error('Invalid stream')
|
|
338
|
+
const outerStream = noiseStream.rawStream
|
|
339
|
+
const protomux =
|
|
340
|
+
noiseStream.userData && Protomux.isProtomux(noiseStream.userData)
|
|
341
|
+
? noiseStream.userData
|
|
342
|
+
: Protomux.from(noiseStream)
|
|
343
|
+
noiseStream.userData = protomux
|
|
344
|
+
|
|
345
|
+
if (this.#attached.has(protomux)) return outerStream
|
|
346
|
+
|
|
347
|
+
protomux.pair(
|
|
348
|
+
{ protocol: 'hypercore/alpha' },
|
|
349
|
+
/** @param {Buffer} discoveryKey */ async (discoveryKey) => {
|
|
350
|
+
this.#l.log(
|
|
351
|
+
'Received discovery key %h from %h',
|
|
352
|
+
discoveryKey,
|
|
353
|
+
stream.noiseStream.remotePublicKey
|
|
354
|
+
)
|
|
355
|
+
this.emit('discovery-key', discoveryKey, protomux)
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
const deferredOpen = pDefer()
|
|
360
|
+
this.#opening.add(deferredOpen.promise)
|
|
361
|
+
// Called when either the peer opens or disconnects before open
|
|
362
|
+
const done = () => {
|
|
363
|
+
deferredOpen.resolve()
|
|
364
|
+
this.#opening.delete(deferredOpen.promise)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const makePeer = this.#makePeer.bind(this, protomux, done)
|
|
368
|
+
|
|
369
|
+
this.#attached.add(protomux)
|
|
370
|
+
// This happens when the connected peer opens the channel
|
|
371
|
+
protomux.pair(
|
|
372
|
+
{ protocol: PROTOCOL_NAME },
|
|
373
|
+
// @ts-ignore - need to update protomux types
|
|
374
|
+
makePeer
|
|
375
|
+
)
|
|
376
|
+
noiseStream.once('close', () => {
|
|
377
|
+
this.#attached.delete(protomux)
|
|
378
|
+
done()
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
noiseStream.opened.then((opened) => {
|
|
382
|
+
// Once the noise stream is opened, we attempt to open the channel ourself
|
|
383
|
+
// (the peer may have already done this, in which case this is a no-op)
|
|
384
|
+
if (opened) makePeer()
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
return outerStream
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* @param {Protomux<OpenedNoiseStream>} protomux
|
|
392
|
+
* @param {() => void} done
|
|
393
|
+
*/
|
|
394
|
+
#makePeer(protomux, done) {
|
|
395
|
+
// #makePeer is called when the noise stream is opened, but it is also
|
|
396
|
+
// called when the connected peer tries to open the channel. We only want
|
|
397
|
+
// one channel, so we ignore attempts to create a peer if the channel is
|
|
398
|
+
// already open
|
|
399
|
+
if (protomux.opened({ protocol: PROTOCOL_NAME })) return done()
|
|
400
|
+
|
|
401
|
+
const peerId = keyToId(protomux.stream.remotePublicKey)
|
|
402
|
+
|
|
403
|
+
// This is written like this because the protomux uses the index within
|
|
404
|
+
// the messages array to define the message id over the wire, so this must
|
|
405
|
+
// stay consistent to avoid breaking protocol changes.
|
|
406
|
+
/** @type {Parameters<typeof Protomux.prototype.createChannel>[0]['messages']} */
|
|
407
|
+
const messages = new Array(MESSAGES_MAX_ID).fill(undefined)
|
|
408
|
+
for (const [type, id] of Object.entries(MESSAGE_TYPES)) {
|
|
409
|
+
messages[id] = {
|
|
410
|
+
encoding: cenc.raw,
|
|
411
|
+
onmessage: (message) => {
|
|
412
|
+
try {
|
|
413
|
+
this.#handleMessage(
|
|
414
|
+
protomux,
|
|
415
|
+
/** @type {keyof typeof MESSAGE_TYPES} */ (type),
|
|
416
|
+
message
|
|
417
|
+
)
|
|
418
|
+
} catch (err) {
|
|
419
|
+
const errorMessage = String(err)
|
|
420
|
+
this.emit('failed-to-handle-message', type, errorMessage)
|
|
421
|
+
this.#l.log(`Error handling ${type} message: ${errorMessage}`)
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const channel = protomux.createChannel({
|
|
428
|
+
userData: null,
|
|
429
|
+
protocol: PROTOCOL_NAME,
|
|
430
|
+
messages,
|
|
431
|
+
onopen: () => {
|
|
432
|
+
peer.connect()
|
|
433
|
+
this.#emitPeers()
|
|
434
|
+
done()
|
|
435
|
+
},
|
|
436
|
+
onclose: () => {
|
|
437
|
+
// TODO: Track reasons for closing
|
|
438
|
+
peer.disconnect()
|
|
439
|
+
// We keep disconnected peers around, but not duplicates
|
|
440
|
+
if (existingDevicePeers.size > 1) {
|
|
441
|
+
// TODO: Decide which existing peer to delete
|
|
442
|
+
existingDevicePeers.delete(peer)
|
|
443
|
+
}
|
|
444
|
+
this.#attached.delete(peer.protomux)
|
|
445
|
+
this.#emitPeers()
|
|
446
|
+
done()
|
|
447
|
+
},
|
|
448
|
+
})
|
|
449
|
+
channel.open()
|
|
450
|
+
|
|
451
|
+
const existingDevicePeers = this.#peers.get(peerId) || new Set()
|
|
452
|
+
const peer = new Peer({
|
|
453
|
+
peerId,
|
|
454
|
+
protomux,
|
|
455
|
+
channel,
|
|
456
|
+
logger: this.#l,
|
|
457
|
+
})
|
|
458
|
+
existingDevicePeers.add(peer)
|
|
459
|
+
this.#peers.set(peerId, existingDevicePeers)
|
|
460
|
+
// Do not emit peers now - will emit when connected
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* @param {Protomux<OpenedNoiseStream>} protomux
|
|
465
|
+
*/
|
|
466
|
+
#getPeerByProtomux(protomux) {
|
|
467
|
+
// We could also index peers by protomux to avoid this, but that would mean
|
|
468
|
+
// we need to keep around protomux references for closed peers, and we keep
|
|
469
|
+
// around closed peers for the lifecycle of the app
|
|
470
|
+
const peerId = keyToId(protomux.stream.remotePublicKey)
|
|
471
|
+
// We could have more than one connection to the same peer
|
|
472
|
+
const devicePeers = this.#peers.get(peerId)
|
|
473
|
+
/** @type {Peer | undefined} */
|
|
474
|
+
let peer
|
|
475
|
+
for (const devicePeer of devicePeers || []) {
|
|
476
|
+
if (devicePeer.protomux === protomux) {
|
|
477
|
+
peer = devicePeer
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return peer
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#getPeers() {
|
|
484
|
+
/** @type {Set<Peer & { info: PeerInfoConnected | PeerInfoDisconnected }>} */
|
|
485
|
+
const peers = new Set()
|
|
486
|
+
for (const devicePeers of this.#peers.values()) {
|
|
487
|
+
const peer = chooseDevicePeer(devicePeers)
|
|
488
|
+
if (peer) peers.add(peer)
|
|
489
|
+
}
|
|
490
|
+
return peers
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#emitPeers() {
|
|
494
|
+
const currentPeers = this.#getPeers()
|
|
495
|
+
const connectedPeerInfos = []
|
|
496
|
+
for (const peer of currentPeers) {
|
|
497
|
+
if (
|
|
498
|
+
!this.#lastEmittedPeers.has(peer) &&
|
|
499
|
+
peer.info.status === 'connected'
|
|
500
|
+
) {
|
|
501
|
+
// Any new peers that have 'connected' status
|
|
502
|
+
this.emit('peer-add', peer.info)
|
|
503
|
+
}
|
|
504
|
+
connectedPeerInfos.push(peer.info)
|
|
505
|
+
}
|
|
506
|
+
if (currentPeers.size > 0 || this.#lastEmittedPeers.size > 0) {
|
|
507
|
+
// Don't emit empty array unless somehow it was not empty before
|
|
508
|
+
this.emit('peers', connectedPeerInfos)
|
|
509
|
+
}
|
|
510
|
+
this.#lastEmittedPeers = currentPeers
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
*
|
|
515
|
+
* @param {Protomux<OpenedNoiseStream>} protomux
|
|
516
|
+
* @param {keyof typeof MESSAGE_TYPES} type
|
|
517
|
+
* @param {Buffer} value
|
|
518
|
+
*/
|
|
519
|
+
#handleMessage(protomux, type, value) {
|
|
520
|
+
const peer = this.#getPeerByProtomux(protomux)
|
|
521
|
+
/* c8 ignore next */
|
|
522
|
+
if (!peer) return // TODO: report error - this should not happen
|
|
523
|
+
switch (type) {
|
|
524
|
+
case 'Invite': {
|
|
525
|
+
const invite = parseInvite(value)
|
|
526
|
+
const peerId = keyToId(protomux.stream.remotePublicKey)
|
|
527
|
+
this.emit('invite', peerId, invite)
|
|
528
|
+
this.#l.log(
|
|
529
|
+
'Invite %h from %S for %h',
|
|
530
|
+
invite.inviteId,
|
|
531
|
+
peerId,
|
|
532
|
+
invite.projectInviteId
|
|
533
|
+
)
|
|
534
|
+
break
|
|
535
|
+
}
|
|
536
|
+
case 'InviteCancel': {
|
|
537
|
+
const inviteCancel = parseInviteCancel(value)
|
|
538
|
+
const peerId = keyToId(protomux.stream.remotePublicKey)
|
|
539
|
+
this.emit('invite-cancel', peerId, inviteCancel)
|
|
540
|
+
this.#l.log(
|
|
541
|
+
'Invite cancel from %S for %h',
|
|
542
|
+
peerId,
|
|
543
|
+
inviteCancel.inviteId
|
|
544
|
+
)
|
|
545
|
+
break
|
|
546
|
+
}
|
|
547
|
+
case 'InviteResponse': {
|
|
548
|
+
const inviteResponse = parseInviteResponse(value)
|
|
549
|
+
const peerId = keyToId(protomux.stream.remotePublicKey)
|
|
550
|
+
this.emit('invite-response', peerId, inviteResponse)
|
|
551
|
+
break
|
|
552
|
+
}
|
|
553
|
+
case 'ProjectJoinDetails': {
|
|
554
|
+
const details = parseProjectJoinDetails(value)
|
|
555
|
+
const peerId = keyToId(protomux.stream.remotePublicKey)
|
|
556
|
+
this.emit('got-project-details', peerId, details)
|
|
557
|
+
break
|
|
558
|
+
}
|
|
559
|
+
case 'DeviceInfo': {
|
|
560
|
+
const deviceInfo = DeviceInfo.decode(value)
|
|
561
|
+
peer.receiveDeviceInfo(deviceInfo)
|
|
562
|
+
this.#emitPeers()
|
|
563
|
+
break
|
|
564
|
+
}
|
|
565
|
+
/* c8 ignore next 2 */
|
|
566
|
+
default:
|
|
567
|
+
throw new ExhaustivenessError(type)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Wait for any connections that are currently opening
|
|
573
|
+
*/
|
|
574
|
+
#waitForPendingConnections() {
|
|
575
|
+
return pTimeout(Promise.all(this.#opening), { milliseconds: SEND_TIMEOUT })
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Get a peer by deviceId. We can have more than one connection per device, in
|
|
580
|
+
* which case we wait for deduplication. Also waits for a peer to be connected
|
|
581
|
+
*
|
|
582
|
+
* @param {string} deviceId
|
|
583
|
+
* @returns {Promise<Peer & { info: PeerInfoConnected | PeerInfoDisconnected }>}
|
|
584
|
+
*/
|
|
585
|
+
async #getPeerByDeviceId(deviceId) {
|
|
586
|
+
const devicePeers = this.#peers.get(deviceId)
|
|
587
|
+
if (!devicePeers || devicePeers.size === 0) {
|
|
588
|
+
throw new UnknownPeerError('Unknown peer ' + deviceId.slice(0, 7))
|
|
589
|
+
}
|
|
590
|
+
const peer = chooseDevicePeer(devicePeers)
|
|
591
|
+
if (peer) return peer
|
|
592
|
+
return new Promise((resolve, reject) => {
|
|
593
|
+
const timeoutId = setTimeout(() => {
|
|
594
|
+
this.off('peers', onPeers)
|
|
595
|
+
reject(new UnknownPeerError('Unknown peer ' + deviceId.slice(0, 7)))
|
|
596
|
+
}, DEDUPE_TIMEOUT)
|
|
597
|
+
|
|
598
|
+
const onPeers = () => {
|
|
599
|
+
if (!devicePeers) return // Not possible, but let's keep TS happy
|
|
600
|
+
const peer = chooseDevicePeer(devicePeers)
|
|
601
|
+
if (!peer) return
|
|
602
|
+
clearTimeout(timeoutId)
|
|
603
|
+
this.off('peers', onPeers)
|
|
604
|
+
resolve(peer)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
this.on('peers', onPeers)
|
|
608
|
+
})
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export { TimeoutError }
|
|
613
|
+
|
|
614
|
+
export class UnknownPeerError extends Error {
|
|
615
|
+
/** @param {string} [message] */
|
|
616
|
+
constructor(message) {
|
|
617
|
+
super(message)
|
|
618
|
+
this.name = 'UnknownPeerError'
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export class PeerDisconnectedError extends Error {
|
|
623
|
+
/** @param {string} [message] */
|
|
624
|
+
constructor(message) {
|
|
625
|
+
super(message)
|
|
626
|
+
this.name = 'PeerDisconnectedError'
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export class PeerFailedConnectionError extends Error {
|
|
631
|
+
/** @param {string} [message] */
|
|
632
|
+
constructor(message) {
|
|
633
|
+
super(message)
|
|
634
|
+
this.name = 'PeerFailedConnectionError'
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* @param {Readonly<Uint8Array>} id
|
|
640
|
+
* @throws if the invite ID is too short
|
|
641
|
+
*/
|
|
642
|
+
function assertInviteIdIsValid(id) {
|
|
643
|
+
assert(id.byteLength >= 32, 'Invite ID must be >= 32 bytes')
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* @param {Readonly<Uint8Array>} data
|
|
648
|
+
* @throws if the data is invalid
|
|
649
|
+
* @returns {Invite}
|
|
650
|
+
*/
|
|
651
|
+
function parseInvite(data) {
|
|
652
|
+
const result = Invite.decode(data)
|
|
653
|
+
assertInviteIdIsValid(result.inviteId)
|
|
654
|
+
assert(result.projectInviteId.length, 'Invite must have project invite ID')
|
|
655
|
+
assert(!isBlank(result.projectName), 'Invite project name cannot be blank')
|
|
656
|
+
assert(!isBlank(result.invitorName), 'Invite invitor name cannot be blank')
|
|
657
|
+
return result
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* @param {Readonly<Uint8Array>} data
|
|
662
|
+
* @throws if the data is invalid
|
|
663
|
+
* @returns {InviteCancel}
|
|
664
|
+
*/
|
|
665
|
+
function parseInviteCancel(data) {
|
|
666
|
+
const result = InviteCancel.decode(data)
|
|
667
|
+
assertInviteIdIsValid(result.inviteId)
|
|
668
|
+
return result
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* @param {Readonly<Uint8Array>} data
|
|
673
|
+
* @throws if the data is invalid
|
|
674
|
+
* @returns {InviteResponse}
|
|
675
|
+
*/
|
|
676
|
+
function parseInviteResponse(data) {
|
|
677
|
+
const result = InviteResponse.decode(data)
|
|
678
|
+
assertInviteIdIsValid(result.inviteId)
|
|
679
|
+
return result
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* @param {Readonly<Uint8Array>} data
|
|
684
|
+
* @throws if the data is invalid
|
|
685
|
+
* @returns {ProjectJoinDetails}
|
|
686
|
+
*/
|
|
687
|
+
function parseProjectJoinDetails(data) {
|
|
688
|
+
const result = ProjectJoinDetails.decode(data)
|
|
689
|
+
assertInviteIdIsValid(result.inviteId)
|
|
690
|
+
assert(result.projectKey.length, 'Project join details must have project key')
|
|
691
|
+
assert(
|
|
692
|
+
result.encryptionKeys?.auth?.byteLength,
|
|
693
|
+
'Project join details must have auth encryption keys'
|
|
694
|
+
)
|
|
695
|
+
return result
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* We can temporarily have more than 1 peer for a device while connections are
|
|
700
|
+
* deduplicating. We don't expose these duplicate connections until only one
|
|
701
|
+
* connection exists per device, however if somehow we end up with more than one
|
|
702
|
+
* connection with a peer and it is not deduplicated, then we expose the oldest
|
|
703
|
+
* connection, or the most recent disconnect.
|
|
704
|
+
*
|
|
705
|
+
* @param {Set<Peer>} devicePeers
|
|
706
|
+
* @returns {undefined | Peer & { info: PeerInfoConnected | PeerInfoDisconnected }}
|
|
707
|
+
*/
|
|
708
|
+
function chooseDevicePeer(devicePeers) {
|
|
709
|
+
if (devicePeers.size === 0) return
|
|
710
|
+
let [pick] = devicePeers
|
|
711
|
+
if (devicePeers.size > 1) {
|
|
712
|
+
for (const peer of devicePeers) {
|
|
713
|
+
// If one of the peers for a device is connecting, skip - we'll wait
|
|
714
|
+
// until it's connected before returning it.
|
|
715
|
+
if (peer.info.status === 'connecting') return
|
|
716
|
+
if (peer.info.status === 'connected') {
|
|
717
|
+
if (pick.info.status !== 'connected') {
|
|
718
|
+
// Always expose the connected peer if there is one
|
|
719
|
+
pick = peer
|
|
720
|
+
} else if (peer.info.connectedAt < pick.info.connectedAt) {
|
|
721
|
+
// If more than one peer is connected, pick the one connected for the longest time
|
|
722
|
+
pick = peer
|
|
723
|
+
}
|
|
724
|
+
} else if (
|
|
725
|
+
pick.info.status === 'disconnected' &&
|
|
726
|
+
peer.info.disconnectedAt > pick.info.disconnectedAt
|
|
727
|
+
) {
|
|
728
|
+
// If all peers are disconnected, pick the most recently disconnected
|
|
729
|
+
pick = peer
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
// Don't expose peers that are connecting, wait until they have connected (or disconnected)
|
|
734
|
+
if (pick.info.status === 'connecting') return
|
|
735
|
+
// @ts-ignore
|
|
736
|
+
return pick
|
|
737
|
+
}
|