@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.
Files changed (186) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +31 -0
  3. package/dist/blob-api.d.ts +92 -0
  4. package/dist/blob-api.d.ts.map +1 -0
  5. package/dist/blob-store/index.d.ts +163 -0
  6. package/dist/blob-store/index.d.ts.map +1 -0
  7. package/dist/blob-store/live-download.d.ts +107 -0
  8. package/dist/blob-store/live-download.d.ts.map +1 -0
  9. package/dist/config-import.d.ts +74 -0
  10. package/dist/config-import.d.ts.map +1 -0
  11. package/dist/constants.d.ts +14 -0
  12. package/dist/constants.d.ts.map +1 -0
  13. package/dist/core-manager/bitfield-rle.d.ts +25 -0
  14. package/dist/core-manager/bitfield-rle.d.ts.map +1 -0
  15. package/dist/core-manager/core-index.d.ts +56 -0
  16. package/dist/core-manager/core-index.d.ts.map +1 -0
  17. package/dist/core-manager/index.d.ts +125 -0
  18. package/dist/core-manager/index.d.ts.map +1 -0
  19. package/dist/core-manager/random-access-file-pool.d.ts +17 -0
  20. package/dist/core-manager/random-access-file-pool.d.ts.map +1 -0
  21. package/dist/core-manager/remote-bitfield.d.ts +146 -0
  22. package/dist/core-manager/remote-bitfield.d.ts.map +1 -0
  23. package/dist/core-ownership.d.ts +112 -0
  24. package/dist/core-ownership.d.ts.map +1 -0
  25. package/dist/datastore/index.d.ts +91 -0
  26. package/dist/datastore/index.d.ts.map +1 -0
  27. package/dist/datatype/index.d.ts +108 -0
  28. package/dist/discovery/local-discovery.d.ts +64 -0
  29. package/dist/discovery/local-discovery.d.ts.map +1 -0
  30. package/dist/errors.d.ts +4 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/fastify-controller.d.ts +27 -0
  33. package/dist/fastify-controller.d.ts.map +1 -0
  34. package/dist/fastify-plugins/blobs.d.ts +6 -0
  35. package/dist/fastify-plugins/blobs.d.ts.map +1 -0
  36. package/dist/fastify-plugins/constants.d.ts +3 -0
  37. package/dist/fastify-plugins/constants.d.ts.map +1 -0
  38. package/dist/fastify-plugins/icons.d.ts +6 -0
  39. package/dist/fastify-plugins/icons.d.ts.map +1 -0
  40. package/dist/fastify-plugins/maps/index.d.ts +11 -0
  41. package/dist/fastify-plugins/maps/index.d.ts.map +1 -0
  42. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +12 -0
  43. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +1 -0
  44. package/dist/fastify-plugins/maps/static-maps.d.ts +11 -0
  45. package/dist/fastify-plugins/maps/static-maps.d.ts.map +1 -0
  46. package/dist/fastify-plugins/utils.d.ts +23 -0
  47. package/dist/fastify-plugins/utils.d.ts.map +1 -0
  48. package/dist/generated/extensions.d.ts +44 -0
  49. package/dist/generated/extensions.d.ts.map +1 -0
  50. package/dist/generated/keys.d.ts +36 -0
  51. package/dist/generated/keys.d.ts.map +1 -0
  52. package/dist/generated/rpc.d.ts +87 -0
  53. package/dist/generated/rpc.d.ts.map +1 -0
  54. package/dist/icon-api.d.ts +109 -0
  55. package/dist/icon-api.d.ts.map +1 -0
  56. package/dist/index-writer/index.d.ts +51 -0
  57. package/dist/index-writer/index.d.ts.map +1 -0
  58. package/dist/index.d.ts +14 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/invite-api.d.ts +70 -0
  61. package/dist/invite-api.d.ts.map +1 -0
  62. package/dist/lib/hashmap.d.ts +62 -0
  63. package/dist/lib/hashmap.d.ts.map +1 -0
  64. package/dist/lib/hypercore-helpers.d.ts +6 -0
  65. package/dist/lib/hypercore-helpers.d.ts.map +1 -0
  66. package/dist/lib/noise-secret-stream-helpers.d.ts +45 -0
  67. package/dist/lib/noise-secret-stream-helpers.d.ts.map +1 -0
  68. package/dist/lib/ponyfills.d.ts +10 -0
  69. package/dist/lib/ponyfills.d.ts.map +1 -0
  70. package/dist/lib/string.d.ts +2 -0
  71. package/dist/lib/string.d.ts.map +1 -0
  72. package/dist/lib/timing-safe-equal.d.ts +15 -0
  73. package/dist/lib/timing-safe-equal.d.ts.map +1 -0
  74. package/dist/local-peers.d.ts +151 -0
  75. package/dist/local-peers.d.ts.map +1 -0
  76. package/dist/logger.d.ts +32 -0
  77. package/dist/logger.d.ts.map +1 -0
  78. package/dist/mapeo-manager.d.ts +178 -0
  79. package/dist/mapeo-manager.d.ts.map +1 -0
  80. package/dist/mapeo-project.d.ts +3233 -0
  81. package/dist/mapeo-project.d.ts.map +1 -0
  82. package/dist/member-api.d.ts +114 -0
  83. package/dist/member-api.d.ts.map +1 -0
  84. package/dist/roles.d.ts +157 -0
  85. package/dist/roles.d.ts.map +1 -0
  86. package/dist/schema/client.d.ts +284 -0
  87. package/dist/schema/client.d.ts.map +1 -0
  88. package/dist/schema/project.d.ts +1812 -0
  89. package/dist/schema/project.d.ts.map +1 -0
  90. package/dist/schema/schema-to-drizzle.d.ts +20 -0
  91. package/dist/schema/schema-to-drizzle.d.ts.map +1 -0
  92. package/dist/schema/types.d.ts +98 -0
  93. package/dist/schema/types.d.ts.map +1 -0
  94. package/dist/schema/utils.d.ts +55 -0
  95. package/dist/schema/utils.d.ts.map +1 -0
  96. package/dist/sync/core-sync-state.d.ts +252 -0
  97. package/dist/sync/core-sync-state.d.ts.map +1 -0
  98. package/dist/sync/namespace-sync-state.d.ts +47 -0
  99. package/dist/sync/namespace-sync-state.d.ts.map +1 -0
  100. package/dist/sync/peer-sync-controller.d.ts +44 -0
  101. package/dist/sync/peer-sync-controller.d.ts.map +1 -0
  102. package/dist/sync/sync-api.d.ts +158 -0
  103. package/dist/sync/sync-api.d.ts.map +1 -0
  104. package/dist/sync/sync-state.d.ts +40 -0
  105. package/dist/sync/sync-state.d.ts.map +1 -0
  106. package/dist/translation-api.d.ts +288 -0
  107. package/dist/translation-api.d.ts.map +1 -0
  108. package/dist/types.d.ts +115 -0
  109. package/dist/types.d.ts.map +1 -0
  110. package/dist/utils.d.ts +115 -0
  111. package/dist/utils.d.ts.map +1 -0
  112. package/dist/utils_types.d.ts +14 -0
  113. package/drizzle/client/0000_bumpy_carnage.sql +33 -0
  114. package/drizzle/client/meta/0000_snapshot.json +199 -0
  115. package/drizzle/client/meta/_journal.json +13 -0
  116. package/drizzle/project/0000_spooky_lady_ursula.sql +192 -0
  117. package/drizzle/project/meta/0000_snapshot.json +1137 -0
  118. package/drizzle/project/meta/_journal.json +13 -0
  119. package/package.json +202 -0
  120. package/src/blob-api.js +139 -0
  121. package/src/blob-store/index.js +325 -0
  122. package/src/blob-store/live-download.js +373 -0
  123. package/src/config-import.js +604 -0
  124. package/src/constants.js +34 -0
  125. package/src/core-manager/bitfield-rle.js +235 -0
  126. package/src/core-manager/core-index.js +87 -0
  127. package/src/core-manager/index.js +504 -0
  128. package/src/core-manager/random-access-file-pool.js +30 -0
  129. package/src/core-manager/remote-bitfield.js +416 -0
  130. package/src/core-ownership.js +235 -0
  131. package/src/datastore/README.md +46 -0
  132. package/src/datastore/index.js +234 -0
  133. package/src/datatype/README.md +33 -0
  134. package/src/datatype/index.d.ts +108 -0
  135. package/src/datatype/index.js +358 -0
  136. package/src/discovery/local-discovery.js +303 -0
  137. package/src/errors.js +5 -0
  138. package/src/fastify-controller.js +84 -0
  139. package/src/fastify-plugins/blobs.js +139 -0
  140. package/src/fastify-plugins/constants.js +5 -0
  141. package/src/fastify-plugins/icons.js +158 -0
  142. package/src/fastify-plugins/maps/index.js +173 -0
  143. package/src/fastify-plugins/maps/offline-fallback-map.js +114 -0
  144. package/src/fastify-plugins/maps/static-maps.js +271 -0
  145. package/src/fastify-plugins/utils.js +52 -0
  146. package/src/generated/README.md +3 -0
  147. package/src/generated/extensions.d.ts +44 -0
  148. package/src/generated/extensions.js +196 -0
  149. package/src/generated/extensions.ts +237 -0
  150. package/src/generated/keys.d.ts +36 -0
  151. package/src/generated/keys.js +148 -0
  152. package/src/generated/keys.ts +185 -0
  153. package/src/generated/rpc.d.ts +87 -0
  154. package/src/generated/rpc.js +389 -0
  155. package/src/generated/rpc.ts +463 -0
  156. package/src/icon-api.js +282 -0
  157. package/src/index-writer/README.md +38 -0
  158. package/src/index-writer/index.js +124 -0
  159. package/src/index.js +16 -0
  160. package/src/invite-api.js +450 -0
  161. package/src/lib/hashmap.js +91 -0
  162. package/src/lib/hypercore-helpers.js +18 -0
  163. package/src/lib/noise-secret-stream-helpers.js +37 -0
  164. package/src/lib/ponyfills.js +25 -0
  165. package/src/lib/string.js +7 -0
  166. package/src/lib/timing-safe-equal.js +34 -0
  167. package/src/local-peers.js +737 -0
  168. package/src/logger.js +99 -0
  169. package/src/mapeo-manager.js +914 -0
  170. package/src/mapeo-project.js +980 -0
  171. package/src/member-api.js +319 -0
  172. package/src/roles.js +412 -0
  173. package/src/schema/client.js +55 -0
  174. package/src/schema/project.js +44 -0
  175. package/src/schema/schema-to-drizzle.js +118 -0
  176. package/src/schema/types.ts +153 -0
  177. package/src/schema/utils.js +51 -0
  178. package/src/sync/core-sync-state.js +440 -0
  179. package/src/sync/namespace-sync-state.js +193 -0
  180. package/src/sync/peer-sync-controller.js +332 -0
  181. package/src/sync/sync-api.js +588 -0
  182. package/src/sync/sync-state.js +63 -0
  183. package/src/translation-api.js +141 -0
  184. package/src/types.ts +149 -0
  185. package/src/utils.js +210 -0
  186. 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
+ }