@comapeo/core 3.0.0-0 → 3.1.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 (53) hide show
  1. package/dist/generated/rpc.d.ts +47 -0
  2. package/dist/generated/rpc.d.ts.map +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/invite/invite-api.d.ts +4 -5
  5. package/dist/invite/invite-api.d.ts.map +1 -1
  6. package/dist/local-peers.d.ts +31 -0
  7. package/dist/local-peers.d.ts.map +1 -1
  8. package/dist/mapeo-manager.d.ts +30 -22
  9. package/dist/mapeo-manager.d.ts.map +1 -1
  10. package/dist/mapeo-project.d.ts +39 -1
  11. package/dist/mapeo-project.d.ts.map +1 -1
  12. package/dist/member-api.d.ts +15 -1
  13. package/dist/member-api.d.ts.map +1 -1
  14. package/dist/schema/client.d.ts +26 -3
  15. package/dist/schema/client.d.ts.map +1 -1
  16. package/dist/sync/sync-api.d.ts +4 -1
  17. package/dist/sync/sync-api.d.ts.map +1 -1
  18. package/drizzle/client/0002_brief_demogoblin.sql +2 -0
  19. package/drizzle/client/meta/0002_snapshot.json +220 -0
  20. package/drizzle/client/meta/_journal.json +7 -0
  21. package/package.json +3 -3
  22. package/src/generated/rpc.d.ts +47 -0
  23. package/src/generated/rpc.js +241 -3
  24. package/src/generated/rpc.ts +280 -1
  25. package/src/invite/invite-api.js +15 -3
  26. package/src/local-peers.js +258 -21
  27. package/src/mapeo-manager.js +60 -20
  28. package/src/mapeo-project.js +21 -3
  29. package/src/member-api.js +67 -10
  30. package/src/schema/client.js +3 -2
  31. package/src/sync/sync-api.js +6 -2
  32. package/dist/blob-store/live-download.d.ts +0 -107
  33. package/dist/blob-store/live-download.d.ts.map +0 -1
  34. package/dist/capabilities.d.ts +0 -121
  35. package/dist/capabilities.d.ts.map +0 -1
  36. package/dist/core-manager/compat.d.ts +0 -4
  37. package/dist/core-manager/compat.d.ts.map +0 -1
  38. package/dist/discovery/dns-sd.d.ts +0 -54
  39. package/dist/discovery/dns-sd.d.ts.map +0 -1
  40. package/dist/fastify-plugins/maps/index.d.ts +0 -11
  41. package/dist/fastify-plugins/maps/index.d.ts.map +0 -1
  42. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +0 -12
  43. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +0 -1
  44. package/dist/fastify-plugins/maps/static-maps.d.ts +0 -11
  45. package/dist/fastify-plugins/maps/static-maps.d.ts.map +0 -1
  46. package/dist/invite-api.d.ts +0 -70
  47. package/dist/invite-api.d.ts.map +0 -1
  48. package/dist/lib/timing-safe-equal.d.ts +0 -15
  49. package/dist/lib/timing-safe-equal.d.ts.map +0 -1
  50. package/dist/media-server.d.ts +0 -36
  51. package/dist/media-server.d.ts.map +0 -1
  52. package/dist/server/ws-core-replicator.d.ts +0 -6
  53. package/dist/server/ws-core-replicator.d.ts.map +0 -1
@@ -1,20 +1,43 @@
1
1
  import { TypedEmitter } from 'tiny-typed-emitter'
2
2
  import Protomux from 'protomux'
3
+ import timingSafeEqual from 'string-timing-safe-equal'
3
4
  import { assert, ExhaustivenessError, keyToId, noop } from './utils.js'
4
5
  import { isBlank } from './lib/string.js'
5
6
  import cenc from 'compact-encoding'
6
7
  import {
7
8
  DeviceInfo,
8
9
  Invite,
10
+ InviteAck,
9
11
  InviteCancel,
12
+ InviteCancelAck,
10
13
  InviteResponse,
14
+ InviteResponseAck,
11
15
  ProjectJoinDetails,
16
+ ProjectJoinDetailsAck,
17
+ DeviceInfo_RPCFeatures,
12
18
  } from './generated/rpc.js'
13
19
  import pDefer from 'p-defer'
14
20
  import { Logger } from './logger.js'
15
21
  import pTimeout, { TimeoutError } from 'p-timeout'
16
22
  /** @import NoiseStream from '@hyperswarm/secret-stream' */
17
23
  /** @import { OpenedNoiseStream } from './lib/noise-secret-stream-helpers.js' */
24
+ /** @import {DeferredPromise} from 'p-defer' */
25
+
26
+ /**
27
+ * @typedef {InviteAck|InviteCancelAck|InviteResponseAck|ProjectJoinDetailsAck} AckResponse
28
+ */
29
+
30
+ /**
31
+ * @callback AckFilter
32
+ * @param {AckResponse} ack
33
+ * @returns {boolean}
34
+ */
35
+
36
+ /**
37
+ * @typedef {object} AckWaiter
38
+ * @property {AckFilter} filter
39
+ * @property {DeferredPromise<void>} deferred
40
+ */
18
41
 
19
42
  // Unique identifier for the mapeo rpc protocol
20
43
  const PROTOCOL_NAME = 'mapeo/rpc'
@@ -33,6 +56,10 @@ const MESSAGE_TYPES = {
33
56
  InviteResponse: 2,
34
57
  ProjectJoinDetails: 3,
35
58
  DeviceInfo: 4,
59
+ InviteAck: 5,
60
+ InviteCancelAck: 6,
61
+ InviteResponseAck: 7,
62
+ ProjectJoinDetailsAck: 8,
36
63
  }
37
64
  const MESSAGES_MAX_ID = Math.max.apply(null, [...Object.values(MESSAGE_TYPES)])
38
65
 
@@ -62,8 +89,14 @@ class Peer {
62
89
  #name
63
90
  /** @type {DeviceInfo['deviceType']} */
64
91
  #deviceType
92
+ /** @type {DeviceInfo['features']} */
93
+ #features = []
65
94
  #connectedAt = 0
66
95
  #disconnectedAt = 0
96
+ #drainedListeners = new Set()
97
+ // Map of type -> Set<{filter, deferred}>
98
+ /** @type Map<keyof typeof MESSAGE_TYPES, Set<AckWaiter>>*/
99
+ #ackWaiters = new Map()
67
100
  #protomux
68
101
  #log
69
102
 
@@ -157,61 +190,218 @@ class Peer {
157
190
  this.#disconnectedAt = Date.now()
158
191
  // This promise should have already resolved, but if the peer never connected then we reject here
159
192
  this.#connected.reject(new PeerFailedConnectionError())
193
+ for (const listener of this.#drainedListeners) {
194
+ listener.reject(new Error('RPC Disconnected before sending'))
195
+ }
196
+ for (const waiters of this.#ackWaiters.values()) {
197
+ for (const { deferred } of waiters) {
198
+ deferred.reject(new Error('RPC disconnected before receiving ACK'))
199
+ }
200
+ }
201
+ this.#ackWaiters.clear()
202
+ this.#drainedListeners.clear()
160
203
  this.#log('disconnected')
161
204
  }
205
+
206
+ // Call this when the stream has drained all data to the network
207
+ drained() {
208
+ for (const listener of this.#drainedListeners) {
209
+ listener.resolve()
210
+ }
211
+ this.#drainedListeners.clear()
212
+ }
213
+
214
+ /**
215
+ * @param {boolean} didWrite
216
+ * @returns {Promise<void>}
217
+ */
218
+ async #waitForDrain(didWrite) {
219
+ if (didWrite) return
220
+ const onDrain = pDefer()
221
+
222
+ this.#drainedListeners.add(onDrain)
223
+
224
+ await onDrain.promise
225
+ }
226
+
227
+ /**
228
+ * Check if RPC Acknowledgement messages are supported by this peer
229
+ * @returns {boolean}
230
+ */
231
+ supportsAck() {
232
+ return this.#features.includes(DeviceInfo_RPCFeatures.ack) ?? false
233
+ }
234
+
235
+ /**
236
+ * @param {keyof typeof MESSAGE_TYPES} type
237
+ * @param {AckFilter} filter
238
+ * @returns {Promise<void>}
239
+ */
240
+ async #waitForAck(type, filter) {
241
+ if (!this.supportsAck()) return
242
+ if (!this.#ackWaiters.has(type)) {
243
+ this.#ackWaiters.set(type, new Set())
244
+ }
245
+ const deferred = pDefer()
246
+ this.#ackWaiters.get(type)?.add({
247
+ deferred,
248
+ filter,
249
+ })
250
+
251
+ await deferred.promise
252
+ }
253
+
254
+ /**
255
+ * @param {keyof typeof MESSAGE_TYPES} type
256
+ * @param {AckResponse} ack
257
+ */
258
+ receiveAck(type, ack) {
259
+ if (!this.supportsAck()) return
260
+ if (!this.#ackWaiters.has(type)) return
261
+ const waiters = this.#ackWaiters.get(type)
262
+ if (!waiters || !waiters.size) {
263
+ return
264
+ }
265
+
266
+ for (const waiter of waiters) {
267
+ if (waiter.filter(ack)) {
268
+ waiter.deferred.resolve()
269
+ waiters.delete(waiter)
270
+ }
271
+ }
272
+ }
273
+
162
274
  /**
163
275
  * @param {Buffer} buf
276
+ * @returns {Promise<void>}
164
277
  */
165
- [kTestOnlySendRawInvite](buf) {
278
+ async [kTestOnlySendRawInvite](buf) {
166
279
  this.#assertConnected()
167
280
  const messageType = MESSAGE_TYPES.Invite
168
- this.#channel.messages[messageType].send(buf)
281
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
169
282
  }
170
- /** @param {Invite} invite */
171
- sendInvite(invite) {
283
+ /**
284
+ * @param {Invite} invite
285
+ * @returns {Promise<void>}
286
+ */
287
+ async sendInvite(invite) {
172
288
  this.#assertConnected('Peer disconnected before sending invite')
173
289
  const buf = Buffer.from(Invite.encode(invite).finish())
174
290
  const messageType = MESSAGE_TYPES.Invite
175
- this.#channel.messages[messageType].send(buf)
291
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
292
+ await this.#waitForAck('InviteAck', ({ inviteId }) =>
293
+ timingSafeEqual(inviteId, invite.inviteId)
294
+ )
176
295
  this.#log('sent invite %h', invite.inviteId)
177
296
  }
178
- /** @param {InviteCancel} inviteCancel */
179
- sendInviteCancel(inviteCancel) {
297
+
298
+ /**
299
+ * @param {Invite} invite
300
+ * @returns {Promise<void>}
301
+ */
302
+ async sendInviteAck({ inviteId }) {
303
+ this.#assertConnected('Peer disconnected before sending invite ack')
304
+ if (!this.supportsAck()) return
305
+ const buf = Buffer.from(InviteAck.encode({ inviteId }).finish())
306
+ const messageType = MESSAGE_TYPES.InviteAck
307
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
308
+ }
309
+
310
+ /**
311
+ * @param {InviteCancel} inviteCancel
312
+ * @returns {Promise<void>}
313
+ */
314
+ async sendInviteCancel(inviteCancel) {
180
315
  this.#assertConnected('Peer disconnected before sending invite cancel')
181
316
  const buf = Buffer.from(InviteCancel.encode(inviteCancel).finish())
182
317
  const messageType = MESSAGE_TYPES.InviteCancel
183
- this.#channel.messages[messageType].send(buf)
318
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
319
+ await this.#waitForAck('InviteCancelAck', ({ inviteId }) =>
320
+ timingSafeEqual(inviteId, inviteCancel.inviteId)
321
+ )
184
322
  this.#log('sent invite cancel %h', inviteCancel.inviteId)
185
323
  }
186
- /** @param {InviteResponse} response */
187
- sendInviteResponse(response) {
324
+
325
+ /**
326
+ * @param {InviteCancel} inviteCancel
327
+ * @returns {Promise<void>}
328
+ */
329
+ async sendInviteCancelAck({ inviteId }) {
330
+ this.#assertConnected('Peer disconnected before sending invite cancel ack')
331
+ if (!this.supportsAck()) return
332
+ const buf = Buffer.from(InviteCancelAck.encode({ inviteId }).finish())
333
+ const messageType = MESSAGE_TYPES.InviteCancelAck
334
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
335
+ }
336
+
337
+ /**
338
+ * @param {InviteResponse} response
339
+ * @returns {Promise<void>}
340
+ */
341
+ async sendInviteResponse(response) {
188
342
  this.#assertConnected('Peer disconnected before sending invite response')
189
343
  const buf = Buffer.from(InviteResponse.encode(response).finish())
190
344
  const messageType = MESSAGE_TYPES.InviteResponse
191
- this.#channel.messages[messageType].send(buf)
345
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
346
+ await this.#waitForAck('InviteResponseAck', ({ inviteId }) =>
347
+ timingSafeEqual(inviteId, response.inviteId)
348
+ )
192
349
  this.#log('sent response for %h: %s', response.inviteId, response.decision)
193
350
  }
351
+
352
+ /**
353
+ * @param {InviteResponse} response
354
+ * @returns {Promise<void>}
355
+ */
356
+ async sendInviteResponseAck({ inviteId }) {
357
+ this.#assertConnected(
358
+ 'Peer disconnected before sending invite response ack'
359
+ )
360
+ if (!this.supportsAck()) return
361
+ const buf = Buffer.from(InviteResponseAck.encode({ inviteId }).finish())
362
+ const messageType = MESSAGE_TYPES.InviteResponseAck
363
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
364
+ }
365
+
194
366
  /** @param {ProjectJoinDetails} details */
195
- sendProjectJoinDetails(details) {
367
+ async sendProjectJoinDetails(details) {
196
368
  this.#assertConnected(
197
369
  'Peer disconnected before sending project join details'
198
370
  )
199
371
  const buf = Buffer.from(ProjectJoinDetails.encode(details).finish())
200
372
  const messageType = MESSAGE_TYPES.ProjectJoinDetails
201
- this.#channel.messages[messageType].send(buf)
373
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
374
+ await this.#waitForAck('ProjectJoinDetailsAck', ({ inviteId }) =>
375
+ timingSafeEqual(inviteId, details.inviteId)
376
+ )
202
377
  this.#log('sent project join details for %h', details.projectKey)
203
378
  }
204
- /** @param {DeviceInfo} deviceInfo */
205
- sendDeviceInfo(deviceInfo) {
379
+ /** @param {ProjectJoinDetails} details */
380
+ async sendProjectJoinDetailsAck({ inviteId }) {
381
+ this.#assertConnected(
382
+ 'Peer disconnected before sending project join details ack'
383
+ )
384
+ if (!this.supportsAck()) return
385
+ const buf = Buffer.from(ProjectJoinDetailsAck.encode({ inviteId }).finish())
386
+ const messageType = MESSAGE_TYPES.ProjectJoinDetailsAck
387
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
388
+ }
389
+
390
+ /**
391
+ * @param {DeviceInfo} deviceInfo
392
+ * @returns {Promise<void>}
393
+ */
394
+ async sendDeviceInfo(deviceInfo) {
206
395
  const buf = Buffer.from(DeviceInfo.encode(deviceInfo).finish())
207
396
  const messageType = MESSAGE_TYPES.DeviceInfo
208
- this.#channel.messages[messageType].send(buf)
397
+ await this.#waitForDrain(this.#channel.messages[messageType].send(buf))
209
398
  this.#log('sent deviceInfo %o', deviceInfo)
210
399
  }
211
400
  /** @param {DeviceInfo} deviceInfo */
212
401
  receiveDeviceInfo(deviceInfo) {
213
402
  this.#name = deviceInfo.name
214
403
  this.#deviceType = deviceInfo.deviceType
404
+ this.#features = deviceInfo.features
215
405
  this.#log('received deviceInfo %o', deviceInfo)
216
406
  }
217
407
  /** @param {string} [message] */
@@ -227,9 +417,13 @@ class Peer {
227
417
  * @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
228
418
  * @property {(peer: PeerInfoConnected) => void} peer-add Emitted when a new peer is connected
229
419
  * @property {(peerId: string, invite: Invite) => void} invite Emitted when an invite is received
420
+ * @property {(peerId: string, invite: InviteAck) => void} invite-ack Emitted when an invite acknowledgement is received
230
421
  * @property {(peerId: string, invite: InviteCancel) => void} invite-cancel Emitted when we receive a cancelation for an invite
422
+ * @property {(peerId: string, invite: InviteCancelAck) => void} invite-cancel-ack Emitted when we receive a cancelation acknowledgement for an invite
231
423
  * @property {(peerId: string, inviteResponse: InviteResponse) => void} invite-response Emitted when an invite response is received
424
+ * @property {(peerId: string, inviteResponse: InviteResponseAck) => void} invite-response-ack Emitted when an invite response acknowledgement is received
232
425
  * @property {(peerId: string, details: ProjectJoinDetails) => void} got-project-details Emitted when project details are received
426
+ * @property {(peerId: string, details: ProjectJoinDetailsAck) => void} got-project-details-ack Emitted when project details are acknowledged as received
233
427
  * @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)
234
428
  * @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
235
429
  */
@@ -273,7 +467,7 @@ export class LocalPeers extends TypedEmitter {
273
467
  async sendInvite(deviceId, invite) {
274
468
  await this.#waitForPendingConnections()
275
469
  const peer = await this.#getPeerByDeviceId(deviceId)
276
- peer.sendInvite(invite)
470
+ await peer.sendInvite(invite)
277
471
  }
278
472
 
279
473
  /**
@@ -284,7 +478,7 @@ export class LocalPeers extends TypedEmitter {
284
478
  async sendInviteCancel(deviceId, inviteCancel) {
285
479
  await this.#waitForPendingConnections()
286
480
  const peer = await this.#getPeerByDeviceId(deviceId)
287
- peer.sendInviteCancel(inviteCancel)
481
+ await peer.sendInviteCancel(inviteCancel)
288
482
  }
289
483
 
290
484
  /**
@@ -296,7 +490,7 @@ export class LocalPeers extends TypedEmitter {
296
490
  async sendInviteResponse(deviceId, inviteResponse) {
297
491
  await this.#waitForPendingConnections()
298
492
  const peer = await this.#getPeerByDeviceId(deviceId)
299
- peer.sendInviteResponse(inviteResponse)
493
+ await peer.sendInviteResponse(inviteResponse)
300
494
  }
301
495
 
302
496
  /**
@@ -306,7 +500,7 @@ export class LocalPeers extends TypedEmitter {
306
500
  async sendProjectJoinDetails(deviceId, details) {
307
501
  await this.#waitForPendingConnections()
308
502
  const peer = await this.#getPeerByDeviceId(deviceId)
309
- peer.sendProjectJoinDetails(details)
503
+ await peer.sendProjectJoinDetails(details)
310
504
  }
311
505
 
312
506
  /**
@@ -317,7 +511,7 @@ export class LocalPeers extends TypedEmitter {
317
511
  async sendDeviceInfo(deviceId, deviceInfo) {
318
512
  await this.#waitForPendingConnections()
319
513
  const peer = await this.#getPeerByDeviceId(deviceId)
320
- peer.sendDeviceInfo(deviceInfo)
514
+ await peer.sendDeviceInfo(deviceInfo)
321
515
  }
322
516
 
323
517
  /**
@@ -449,6 +643,9 @@ export class LocalPeers extends TypedEmitter {
449
643
  this.#emitPeers()
450
644
  done()
451
645
  },
646
+ ondrain: () => {
647
+ peer.drained()
648
+ },
452
649
  })
453
650
  channel.open()
454
651
 
@@ -529,6 +726,9 @@ export class LocalPeers extends TypedEmitter {
529
726
  const invite = parseInvite(value)
530
727
  const peerId = keyToId(protomux.stream.remotePublicKey)
531
728
  this.emit('invite', peerId, invite)
729
+ peer.sendInviteAck(invite).catch((e) => {
730
+ this.#l.log(`Error sending invite ack ${e.stack}`)
731
+ })
532
732
  this.#l.log(
533
733
  'Invite %h from %S for %h',
534
734
  invite.inviteId,
@@ -541,6 +741,9 @@ export class LocalPeers extends TypedEmitter {
541
741
  const inviteCancel = parseInviteCancel(value)
542
742
  const peerId = keyToId(protomux.stream.remotePublicKey)
543
743
  this.emit('invite-cancel', peerId, inviteCancel)
744
+ peer.sendInviteCancelAck(inviteCancel).catch((e) => {
745
+ this.#l.log(`Error sending invite cancel ack ${e.stack}`)
746
+ })
544
747
  this.#l.log(
545
748
  'Invite cancel from %S for %h',
546
749
  peerId,
@@ -552,12 +755,18 @@ export class LocalPeers extends TypedEmitter {
552
755
  const inviteResponse = parseInviteResponse(value)
553
756
  const peerId = keyToId(protomux.stream.remotePublicKey)
554
757
  this.emit('invite-response', peerId, inviteResponse)
758
+ peer.sendInviteResponseAck(inviteResponse).catch((e) => {
759
+ this.#l.log(`Error sending invite response ack ${e.stack}`)
760
+ })
555
761
  break
556
762
  }
557
763
  case 'ProjectJoinDetails': {
558
764
  const details = parseProjectJoinDetails(value)
559
765
  const peerId = keyToId(protomux.stream.remotePublicKey)
560
766
  this.emit('got-project-details', peerId, details)
767
+ peer.sendProjectJoinDetailsAck(details).catch((e) => {
768
+ this.#l.log(`Error sending project details ack ${e.stack}`)
769
+ })
561
770
  break
562
771
  }
563
772
  case 'DeviceInfo': {
@@ -566,6 +775,34 @@ export class LocalPeers extends TypedEmitter {
566
775
  this.#emitPeers()
567
776
  break
568
777
  }
778
+ case 'InviteAck': {
779
+ const ack = InviteAck.decode(value)
780
+ peer.receiveAck('InviteAck', ack)
781
+ const peerId = keyToId(protomux.stream.remotePublicKey)
782
+ this.emit('invite-ack', peerId, ack)
783
+ break
784
+ }
785
+ case 'InviteCancelAck': {
786
+ const ack = InviteCancelAck.decode(value)
787
+ peer.receiveAck('InviteCancelAck', ack)
788
+ const peerId = keyToId(protomux.stream.remotePublicKey)
789
+ this.emit('invite-cancel-ack', peerId, ack)
790
+ break
791
+ }
792
+ case 'InviteResponseAck': {
793
+ const ack = InviteResponseAck.decode(value)
794
+ peer.receiveAck('InviteResponseAck', ack)
795
+ const peerId = keyToId(protomux.stream.remotePublicKey)
796
+ this.emit('invite-response-ack', peerId, ack)
797
+ break
798
+ }
799
+ case 'ProjectJoinDetailsAck': {
800
+ const ack = ProjectJoinDetailsAck.decode(value)
801
+ peer.receiveAck('ProjectJoinDetailsAck', ack)
802
+ const peerId = keyToId(protomux.stream.remotePublicKey)
803
+ this.emit('got-project-details-ack', peerId, ack)
804
+ break
805
+ }
569
806
  /* c8 ignore next 2 */
570
807
  default:
571
808
  throw new ExhaustivenessError(type)
@@ -25,6 +25,10 @@ import {
25
25
  projectSettingsTable,
26
26
  } from './schema/client.js'
27
27
  import { ProjectKeys } from './generated/keys.js'
28
+ import {
29
+ DeviceInfo_RPCFeatures,
30
+ DeviceInfo_DeviceType,
31
+ } from './generated/rpc.js'
28
32
  import {
29
33
  deNullify,
30
34
  getDeviceId,
@@ -52,13 +56,17 @@ import {
52
56
  kRescindFullStopRequest,
53
57
  } from './sync/sync-api.js'
54
58
  import { NotFoundError } from './errors.js'
55
- /** @import { ProjectSettingsValue as ProjectValue } from '@comapeo/schema' */
59
+ import { WebSocket } from 'ws'
60
+
56
61
  /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */
57
62
  /** @import { SetNonNullable } from 'type-fest' */
63
+ /** @import { ProjectJoinDetails, } from './generated/rpc.js' */
58
64
  /** @import { CoreStorage, Namespace } from './types.js' */
59
- /** @import { DeviceInfoParam } from './schema/client.js' */
65
+ /** @import { DeviceInfoParam, ProjectInfo } from './schema/client.js' */
60
66
 
61
67
  /** @typedef {SetNonNullable<ProjectKeys, 'encryptionKeys'>} ValidatedProjectKeys */
68
+ /** @typedef {Pick<ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string, projectColor?: string, projectDescription?: string }} ProjectToAddDetails */
69
+ /** @typedef {{ projectId: string, createdAt?: string, updatedAt?: string, name?: string, projectColor?: string, projectDescription?: string }} ListedProject */
62
70
 
63
71
  const CLIENT_SQLITE_FILE_NAME = 'client.db'
64
72
 
@@ -113,6 +121,7 @@ export class MapeoManager extends TypedEmitter {
113
121
  #loggerBase
114
122
  #l
115
123
  #defaultConfigPath
124
+ #makeWebsocket
116
125
 
117
126
  /**
118
127
  * @param {Object} opts
@@ -126,6 +135,7 @@ export class MapeoManager extends TypedEmitter {
126
135
  * @param {string} [opts.customMapPath] File path to a locally stored Styled Map Package (SMP).
127
136
  * @param {string} [opts.fallbackMapPath] File path to a locally stored Styled Map Package (SMP)
128
137
  * @param {string} [opts.defaultOnlineStyleUrl] URL for an online-hosted StyleJSON asset.
138
+ * @param {(url: string) => WebSocket} [opts.makeWebsocket]
129
139
  */
130
140
  constructor({
131
141
  rootKey,
@@ -138,11 +148,13 @@ export class MapeoManager extends TypedEmitter {
138
148
  customMapPath,
139
149
  fallbackMapPath = DEFAULT_FALLBACK_MAP_FILE_PATH,
140
150
  defaultOnlineStyleUrl = DEFAULT_ONLINE_STYLE_URL,
151
+ makeWebsocket = (url) => new WebSocket(url),
141
152
  }) {
142
153
  super()
143
154
  this.#keyManager = new KeyManager(rootKey)
144
155
  this.#deviceId = getDeviceId(this.#keyManager)
145
156
  this.#defaultConfigPath = defaultConfigPath
157
+ this.#makeWebsocket = makeWebsocket
146
158
  const logger = (this.#loggerBase = new Logger({ deviceId: this.#deviceId }))
147
159
  this.#l = Logger.create('manager', logger)
148
160
  this.#dbFolder = dbFolder
@@ -270,9 +282,14 @@ export class MapeoManager extends TypedEmitter {
270
282
  const deviceInfo = this.getDeviceInfo()
271
283
  if (!hasSavedDeviceInfo(deviceInfo)) return
272
284
 
285
+ const deviceInfoToSend = {
286
+ ...deviceInfo,
287
+ features: [DeviceInfo_RPCFeatures.ack],
288
+ }
289
+
273
290
  const peerId = keyToId(openedNoiseStream.remotePublicKey)
274
291
 
275
- return this.#localPeers.sendDeviceInfo(peerId, deviceInfo)
292
+ return this.#localPeers.sendDeviceInfo(peerId, deviceInfoToSend)
276
293
  })
277
294
  .catch((e) => {
278
295
  // Ignore error but log
@@ -318,7 +335,7 @@ export class MapeoManager extends TypedEmitter {
318
335
  * @param {string} opts.projectPublicId
319
336
  * @param {Readonly<Buffer>} opts.projectInviteId
320
337
  * @param {ProjectKeys} opts.projectKeys
321
- * @param {Readonly<{ name?: string }>} [opts.projectInfo]
338
+ * @param {Readonly<ProjectInfo>} [opts.projectInfo]
322
339
  */
323
340
  #saveToProjectKeysTable({
324
341
  projectId,
@@ -353,15 +370,16 @@ export class MapeoManager extends TypedEmitter {
353
370
 
354
371
  /**
355
372
  * Create a new project.
356
- * @param {(
357
- * import('type-fest').Simplify<(
358
- * Partial<Pick<ProjectValue, 'name'>> &
359
- * { configPath?: string }
360
- * )>
361
- * )} [options]
373
+ *
374
+ * @param {{ name?: string, configPath?: string, projectColor?: string, projectDescription?: string }} [options]
362
375
  * @returns {Promise<string>} Project public id
363
376
  */
364
- async createProject({ name, configPath = this.#defaultConfigPath } = {}) {
377
+ async createProject({
378
+ name,
379
+ configPath = this.#defaultConfigPath,
380
+ projectColor,
381
+ projectDescription,
382
+ } = {}) {
365
383
  // 1. Create project keypair
366
384
  const projectKeypair = KeyManager.generateProjectKeypair()
367
385
 
@@ -406,7 +424,11 @@ export class MapeoManager extends TypedEmitter {
406
424
  })
407
425
 
408
426
  // 5. Write project settings to project instance
409
- await project.$setProjectSettings({ name })
427
+ await project.$setProjectSettings({
428
+ name,
429
+ projectColor,
430
+ projectDescription,
431
+ })
410
432
 
411
433
  // 6. Write device info into project
412
434
  const deviceInfo = this.getDeviceInfo()
@@ -495,13 +517,14 @@ export class MapeoManager extends TypedEmitter {
495
517
  logger: this.#loggerBase,
496
518
  getMediaBaseUrl: this.#getMediaBaseUrl.bind(this),
497
519
  isArchiveDevice,
520
+ makeWebsocket: this.#makeWebsocket,
498
521
  })
499
522
  await project[kClearDataIfLeft]()
500
523
  return project
501
524
  }
502
525
 
503
526
  /**
504
- * @returns {Promise<Array<Pick<ProjectValue, 'name'> & { projectId: string, createdAt?: string, updatedAt?: string}>>}
527
+ * @returns {Promise<Array<ListedProject>>}
505
528
  */
506
529
  async listProjects() {
507
530
  // We use the project keys table as the source of truth for projects that exist
@@ -522,11 +545,13 @@ export class MapeoManager extends TypedEmitter {
522
545
  createdAt: projectSettingsTable.createdAt,
523
546
  updatedAt: projectSettingsTable.updatedAt,
524
547
  name: projectSettingsTable.name,
548
+ projectColor: projectSettingsTable.projectColor,
549
+ projectDescription: projectSettingsTable.projectDescription,
525
550
  })
526
551
  .from(projectSettingsTable)
527
552
  .all()
528
553
 
529
- /** @type {Array<Pick<ProjectValue, 'name'> & { projectId: string, createdAt?: string, updatedAt?: string, createdBy?: string }>} */
554
+ /** @type {Array<ListedProject>} */
530
555
  const result = []
531
556
 
532
557
  for (const {
@@ -544,6 +569,11 @@ export class MapeoManager extends TypedEmitter {
544
569
  createdAt: existingProject?.createdAt,
545
570
  updatedAt: existingProject?.updatedAt,
546
571
  name: existingProject?.name || projectInfo.name,
572
+ projectColor:
573
+ existingProject?.projectColor || projectInfo.projectColor,
574
+ projectDescription:
575
+ existingProject?.projectDescription ||
576
+ projectInfo.projectDescription,
547
577
  })
548
578
  )
549
579
  }
@@ -556,12 +586,18 @@ export class MapeoManager extends TypedEmitter {
556
586
  * await `project.$waitForInitialSync()` to ensure that the device has
557
587
  * downloaded their proof of project membership and the project config.
558
588
  *
559
- * @param {Pick<import('./generated/rpc.js').ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string }} projectJoinDetails
589
+ * @param {ProjectToAddDetails} projectToAddDetails
560
590
  * @param {{ waitForSync?: boolean }} [opts] Set opts.waitForSync = false to not wait for sync during addProject()
561
591
  * @returns {Promise<string>}
562
592
  */
563
593
  addProject = async (
564
- { projectKey, encryptionKeys, projectName },
594
+ {
595
+ projectKey,
596
+ encryptionKeys,
597
+ projectName,
598
+ projectColor,
599
+ projectDescription,
600
+ },
565
601
  { waitForSync = true } = {}
566
602
  ) => {
567
603
  const projectPublicId = projectKeyToPublicId(projectKey)
@@ -599,7 +635,7 @@ export class MapeoManager extends TypedEmitter {
599
635
  projectKey,
600
636
  encryptionKeys,
601
637
  },
602
- projectInfo: { name: projectName },
638
+ projectInfo: { name: projectName, projectColor, projectDescription },
603
639
  })
604
640
 
605
641
  // Any errors from here we need to remove project from db because it has not
@@ -720,7 +756,10 @@ export class MapeoManager extends TypedEmitter {
720
756
  * @param {T} deviceInfo
721
757
  */
722
758
  async setDeviceInfo(deviceInfo) {
723
- const values = { deviceId: this.#deviceId, deviceInfo }
759
+ const values = {
760
+ deviceId: this.#deviceId,
761
+ deviceInfo,
762
+ }
724
763
  this.#db
725
764
  .insert(deviceSettingsTable)
726
765
  .values(values)
@@ -747,6 +786,7 @@ export class MapeoManager extends TypedEmitter {
747
786
  const deviceInfoToSend = {
748
787
  ...deviceInfo,
749
788
  deviceType,
789
+ features: [DeviceInfo_RPCFeatures.ack],
750
790
  }
751
791
  await Promise.all(
752
792
  this.#localPeers.peers
@@ -764,7 +804,7 @@ export class MapeoManager extends TypedEmitter {
764
804
  * @returns {(
765
805
  * {
766
806
  * deviceId: string;
767
- * deviceType: DeviceInfoParam['deviceType']
807
+ * deviceType: DeviceInfoParam['deviceType'];
768
808
  * } & Partial<DeviceInfoParam>
769
809
  * )}
770
810
  */
@@ -776,7 +816,7 @@ export class MapeoManager extends TypedEmitter {
776
816
  .get()
777
817
  return {
778
818
  deviceId: this.#deviceId,
779
- deviceType: 'device_type_unspecified',
819
+ deviceType: DeviceInfo_DeviceType.device_type_unspecified,
780
820
  ...row?.deviceInfo,
781
821
  }
782
822
  }