@comapeo/core 2.3.1 → 3.0.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 (73) hide show
  1. package/dist/blob-store/entries-stream.d.ts.map +1 -1
  2. package/dist/blob-store/index.d.ts +27 -15
  3. package/dist/blob-store/index.d.ts.map +1 -1
  4. package/dist/blob-store/live-download.d.ts +107 -0
  5. package/dist/blob-store/live-download.d.ts.map +1 -0
  6. package/dist/capabilities.d.ts +121 -0
  7. package/dist/capabilities.d.ts.map +1 -0
  8. package/dist/core-manager/compat.d.ts +4 -0
  9. package/dist/core-manager/compat.d.ts.map +1 -0
  10. package/dist/core-manager/index.d.ts +4 -4
  11. package/dist/core-manager/index.d.ts.map +1 -1
  12. package/dist/discovery/dns-sd.d.ts +54 -0
  13. package/dist/discovery/dns-sd.d.ts.map +1 -0
  14. package/dist/errors.d.ts +16 -0
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/fastify-plugins/maps/index.d.ts +11 -0
  17. package/dist/fastify-plugins/maps/index.d.ts.map +1 -0
  18. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +12 -0
  19. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +1 -0
  20. package/dist/fastify-plugins/maps/static-maps.d.ts +11 -0
  21. package/dist/fastify-plugins/maps/static-maps.d.ts.map +1 -0
  22. package/dist/generated/extensions.d.ts +7 -0
  23. package/dist/generated/extensions.d.ts.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/invite/invite-api.d.ts +112 -0
  26. package/dist/invite/invite-api.d.ts.map +1 -0
  27. package/dist/invite/invite-state-machine.d.ts +510 -0
  28. package/dist/invite/invite-state-machine.d.ts.map +1 -0
  29. package/dist/lib/timing-safe-equal.d.ts +15 -0
  30. package/dist/lib/timing-safe-equal.d.ts.map +1 -0
  31. package/dist/local-peers.d.ts.map +1 -1
  32. package/dist/mapeo-manager.d.ts +1 -1
  33. package/dist/mapeo-manager.d.ts.map +1 -1
  34. package/dist/mapeo-project.d.ts.map +1 -1
  35. package/dist/media-server.d.ts +36 -0
  36. package/dist/media-server.d.ts.map +1 -0
  37. package/dist/member-api.d.ts.map +1 -1
  38. package/dist/server/ws-core-replicator.d.ts +6 -0
  39. package/dist/server/ws-core-replicator.d.ts.map +1 -0
  40. package/dist/sync/core-sync-state.d.ts +14 -6
  41. package/dist/sync/core-sync-state.d.ts.map +1 -1
  42. package/dist/sync/namespace-sync-state.d.ts +3 -13
  43. package/dist/sync/namespace-sync-state.d.ts.map +1 -1
  44. package/dist/sync/sync-api.d.ts +17 -25
  45. package/dist/sync/sync-api.d.ts.map +1 -1
  46. package/dist/sync/sync-state.d.ts +3 -13
  47. package/dist/sync/sync-state.d.ts.map +1 -1
  48. package/dist/types.d.ts +6 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/utils.d.ts +2 -2
  51. package/dist/utils.d.ts.map +1 -1
  52. package/package.json +5 -3
  53. package/src/blob-store/entries-stream.js +43 -18
  54. package/src/blob-store/index.js +161 -19
  55. package/src/core-manager/index.js +14 -7
  56. package/src/errors.js +33 -0
  57. package/src/generated/extensions.d.ts +7 -0
  58. package/src/generated/extensions.js +12 -2
  59. package/src/generated/extensions.ts +19 -1
  60. package/src/invite/StateDiagram.md +47 -0
  61. package/src/invite/invite-api.js +387 -0
  62. package/src/invite/invite-state-machine.js +208 -0
  63. package/src/local-peers.js +12 -9
  64. package/src/mapeo-manager.js +1 -1
  65. package/src/mapeo-project.js +7 -76
  66. package/src/member-api.js +5 -4
  67. package/src/sync/core-sync-state.js +41 -14
  68. package/src/sync/namespace-sync-state.js +25 -22
  69. package/src/sync/sync-api.js +12 -43
  70. package/src/sync/sync-state.js +9 -19
  71. package/src/types.ts +7 -1
  72. package/src/utils.js +8 -3
  73. package/src/invite-api.js +0 -450
@@ -0,0 +1,387 @@
1
+ import { TypedEmitter } from 'tiny-typed-emitter'
2
+ import { InviteResponse_Decision } from '../generated/rpc.js'
3
+ import { assert, keyToId, noop } from '../utils.js'
4
+ import HashMap from '../lib/hashmap.js'
5
+ import timingSafeEqual from 'string-timing-safe-equal'
6
+ import { Logger } from '../logger.js'
7
+ import { createActor, fromPromise, toPromise } from 'xstate'
8
+ import { inviteStateMachine } from './invite-state-machine.js'
9
+ import {
10
+ NotFoundError,
11
+ AlreadyJoinedError,
12
+ InviteSendError,
13
+ } from '../errors.js'
14
+
15
+ /** @import { MapBuffers } from '../types.js' */
16
+ /**
17
+ * @import {
18
+ * Invite as InviteRpcMessage,
19
+ * InviteCancel,
20
+ * ProjectJoinDetails
21
+ * } from '../generated/rpc.js'
22
+ */
23
+
24
+ // There are three slightly different invite types:
25
+ //
26
+ // - InviteRpcMessage comes from the protobuf.
27
+ // - InviteInternal adds a locally-generated receive timestamp.
28
+ // - Invite is the externally-facing type.
29
+
30
+ /**
31
+ * @internal
32
+ * @typedef {InviteRpcMessage & { receivedAt: number }} InviteInternal
33
+ */
34
+ /** @typedef {ExtractStateString<import('xstate').StateValueFrom<typeof inviteStateMachine>>} InviteState */
35
+ /** @typedef {import('type-fest').Simplify<MapBuffers<InviteInternal> & { invitorDeviceId: string } & ({ state: Exclude<InviteState, 'error'> } | { state: 'error', error: Error })>} Invite */
36
+
37
+ /**
38
+ * @typedef {import('xstate').ActorRefFrom<typeof inviteStateMachine>} invite.actor
39
+ */
40
+
41
+ /**
42
+ * @typedef {Object} InviteApiEvents
43
+ * @property {(invite: Invite) => void} invite-received
44
+ * @property {(invite: Invite) => void} invite-updated
45
+ */
46
+
47
+ /**
48
+ * @typedef {(projectDetails: Pick<ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string }) => Promise<string>} AddProjectQuery
49
+ */
50
+
51
+ /**
52
+ * @extends {TypedEmitter<InviteApiEvents>}
53
+ */
54
+ export class InviteApi extends TypedEmitter {
55
+ #getProjectByInviteId
56
+ #addProject
57
+ /** @type {HashMap<string | Buffer, { value: InviteInternal, actor: invite.actor, peerId: string }>} */
58
+ #invites = new HashMap(keyToId)
59
+ #l
60
+
61
+ /**
62
+ * @param {Object} options
63
+ * @param {import('../local-peers.js').LocalPeers} options.rpc
64
+ * @param {object} options.queries
65
+ * @param {(projectInviteId: Readonly<Buffer>) => undefined | { projectPublicId: string }} options.queries.getProjectByInviteId
66
+ * @param {AddProjectQuery} options.queries.addProject
67
+ * @param {Logger} [options.logger]
68
+ */
69
+ constructor({ rpc, queries, logger }) {
70
+ super()
71
+
72
+ this.#l = Logger.create('InviteApi', logger)
73
+
74
+ this.rpc = rpc
75
+ this.#getProjectByInviteId = queries.getProjectByInviteId
76
+ this.#addProject = queries.addProject
77
+
78
+ this.rpc.on('invite', (...args) => {
79
+ try {
80
+ this.#handleNewInvite(...args)
81
+ } catch (err) {
82
+ console.error('Error handling invite', err)
83
+ }
84
+ })
85
+
86
+ this.rpc.on('invite-cancel', (_peerId, inviteCancel) => {
87
+ try {
88
+ this.#handleInviteCancel(inviteCancel)
89
+ } catch (err) {
90
+ console.error('Error handling invite cancel', err)
91
+ }
92
+ })
93
+
94
+ this.rpc.on('got-project-details', (peerId, projectJoinDetails) => {
95
+ try {
96
+ this.#handleGotProjectDetails(peerId, projectJoinDetails)
97
+ } catch (err) {
98
+ console.error('Error handling got-project-details', err)
99
+ }
100
+ })
101
+ }
102
+
103
+ /** @param {Buffer} projectInviteId */
104
+ #isJoiningProject(projectInviteId) {
105
+ for (const { value, actor } of this.#invites.values()) {
106
+ const state = actor.getSnapshot()
107
+ if (
108
+ state.matches('joining') &&
109
+ value.projectInviteId.equals(projectInviteId)
110
+ ) {
111
+ return true
112
+ }
113
+ }
114
+ return false
115
+ }
116
+
117
+ /**
118
+ * @param {string} peerId
119
+ * @param {InviteRpcMessage} inviteRpcMessage
120
+ */
121
+ #handleNewInvite(peerId, inviteRpcMessage) {
122
+ const { inviteId, projectInviteId, projectName } = inviteRpcMessage
123
+ const invite = { ...inviteRpcMessage, receivedAt: Date.now() }
124
+
125
+ this.#l.log('Received invite %h from %S', inviteId, peerId)
126
+
127
+ const hasAlreadyReceivedThisInvite = this.#invites.has(inviteId)
128
+ if (hasAlreadyReceivedThisInvite) {
129
+ this.#l.log('Invite %h: already received this invite', inviteId)
130
+ return
131
+ }
132
+
133
+ const isAlreadyMember = Boolean(this.#getProjectByInviteId(projectInviteId))
134
+ if (isAlreadyMember) {
135
+ this.#l.log('Invite %h: already in project', inviteId)
136
+ this.rpc
137
+ .sendInviteResponse(peerId, {
138
+ decision: InviteResponse_Decision.ALREADY,
139
+ inviteId,
140
+ })
141
+ .catch(noop)
142
+ return
143
+ }
144
+
145
+ const actor = createActor(
146
+ inviteStateMachine.provide({
147
+ actors: {
148
+ sendInviteResponse: fromPromise(async ({ input: { decision } }) => {
149
+ return this.rpc.sendInviteResponse(peerId, { decision, inviteId })
150
+ }),
151
+ addProject: fromPromise(async ({ input: projectDetails }) => {
152
+ return this.#addProject({ ...projectDetails, projectName })
153
+ }),
154
+ },
155
+ guards: {
156
+ isNotAlreadyJoiningOrInProject: () => {
157
+ const isJoining = this.#isJoiningProject(projectInviteId)
158
+ const isAlreadyMember = Boolean(
159
+ this.#getProjectByInviteId(projectInviteId)
160
+ )
161
+ return !isJoining && !isAlreadyMember
162
+ },
163
+ },
164
+ })
165
+ )
166
+
167
+ this.#invites.set(inviteId, { value: invite, actor, peerId })
168
+
169
+ this.emit('invite-received', toInvite(invite, actor.getSnapshot(), peerId))
170
+ actor.start()
171
+ // Subscribe after start so that initial state (invite-received) is not emitted twice
172
+ actor.subscribe((snapshot) => {
173
+ this.emit('invite-updated', toInvite(invite, snapshot, peerId))
174
+ })
175
+ }
176
+
177
+ /**
178
+ * @param {InviteCancel} inviteCancel
179
+ */
180
+ #handleInviteCancel(inviteCancel) {
181
+ const { inviteId } = inviteCancel
182
+
183
+ this.#l.log('Received invite cancel for invite ID %h', inviteId)
184
+
185
+ const invite = this.#invites.get(inviteId)
186
+ assert(!!invite, `Cannot find invite ${inviteId.toString('hex')}`)
187
+
188
+ // TODO: Move this logging to the state machine
189
+ const state = invite.actor.getSnapshot()
190
+ if (!state.can({ type: 'CANCEL_INVITE' })) {
191
+ this.#l.log(
192
+ 'Received invite cancel for %h but invite is already in state %o',
193
+ inviteId,
194
+ state.value
195
+ )
196
+ return
197
+ }
198
+
199
+ invite.actor.send({ type: 'CANCEL_INVITE' })
200
+ }
201
+
202
+ /**
203
+ * @param {string} peerId
204
+ * @param {ProjectJoinDetails} projectJoinDetails
205
+ */
206
+ #handleGotProjectDetails(peerId, projectJoinDetails) {
207
+ const invite = this.#invites.get(projectJoinDetails.inviteId)
208
+ if (!invite) {
209
+ this.#l.log(
210
+ 'Received project details for %h but invite is not found',
211
+ projectJoinDetails.inviteId
212
+ )
213
+ return
214
+ }
215
+ if (!timingSafeEqual(peerId, invite.peerId)) {
216
+ this.#l.log(
217
+ 'Received project details for %h but peer ID does not match',
218
+ projectJoinDetails.inviteId
219
+ )
220
+ return
221
+ }
222
+ invite.actor.send({
223
+ type: 'RECEIVE_PROJECT_DETAILS',
224
+ ...projectJoinDetails,
225
+ })
226
+ }
227
+
228
+ /**
229
+ * Get all invites (in all)
230
+ *
231
+ * @returns {Array<Invite>}
232
+ */
233
+ getMany() {
234
+ /** @type {Array<Invite>} */
235
+ const invites = []
236
+ for (const { value, actor, peerId } of this.#invites.values()) {
237
+ const snapshot = actor.getSnapshot()
238
+ invites.push(toInvite(value, snapshot, peerId))
239
+ }
240
+ return invites
241
+ }
242
+
243
+ /**
244
+ * Get an invite by inviteId
245
+ *
246
+ * @param {string} inviteIdString
247
+ * @returns {Invite}
248
+ */
249
+ getById(inviteIdString) {
250
+ const inviteId = Buffer.from(inviteIdString, 'hex')
251
+ const invite = this.#invites.get(inviteId)
252
+ if (!invite) {
253
+ throw new NotFoundError(`Cannot find invite ${inviteIdString}`)
254
+ }
255
+ return toInvite(invite.value, invite.actor.getSnapshot(), invite.peerId)
256
+ }
257
+
258
+ /**
259
+ * Attempt to accept the invite.
260
+ *
261
+ * This can fail if the invitor has canceled the invite or if you cannot
262
+ * connect to the invitor's device.
263
+ *
264
+ * If the invite is accepted and you had other invites to the same project,
265
+ * those invites are removed, and the invitors are told that you're already
266
+ * part of this project.
267
+ *
268
+ * @param {Pick<Invite, 'inviteId'>} invite
269
+ * @returns {Promise<string>}
270
+ */
271
+ async accept({ inviteId: inviteIdString }) {
272
+ const inviteId = Buffer.from(inviteIdString, 'hex')
273
+
274
+ const invite = this.#invites.get(inviteId)
275
+ assert(!!invite, new NotFoundError(`Cannot find invite ${inviteIdString}`))
276
+ assertCanSend(invite.actor, { type: 'ACCEPT_INVITE' })
277
+
278
+ this.#l.log('Accepting invite %h', inviteId)
279
+ invite.actor.send({ type: 'ACCEPT_INVITE' })
280
+
281
+ for (const { value, actor } of this.#invites.values()) {
282
+ if (value.inviteId.equals(inviteId)) continue
283
+ const inviteIsForSameProject = value.projectInviteId.equals(
284
+ invite.value.projectInviteId
285
+ )
286
+ if (inviteIsForSameProject) {
287
+ actor.send({ type: 'ALREADY_IN_PROJECT' })
288
+ }
289
+ }
290
+
291
+ const { projectPublicId } = await toPromise(invite.actor)
292
+
293
+ if (!projectPublicId) {
294
+ const { context, value } = invite.actor.getSnapshot()
295
+ throw value === 'respondedAlready'
296
+ ? new AlreadyJoinedError('Already joining or in project')
297
+ : context.error || new Error('Unknown error')
298
+ }
299
+
300
+ return projectPublicId
301
+ }
302
+
303
+ /**
304
+ * @param {Pick<Invite, 'inviteId'>} invite
305
+ * @returns {void}
306
+ */
307
+ reject({ inviteId: inviteIdString }) {
308
+ const inviteId = Buffer.from(inviteIdString, 'hex')
309
+
310
+ const invite = this.#invites.get(inviteId)
311
+ assert(!!invite, `Cannot find invite ${inviteIdString}`)
312
+ assertCanSend(invite.actor, { type: 'REJECT_INVITE' })
313
+
314
+ this.#l.log('Rejecting invite %h', inviteId)
315
+ invite.actor.send({ type: 'REJECT_INVITE' })
316
+ }
317
+ }
318
+
319
+ /**
320
+ * @param {InviteInternal} internal
321
+ * @param {import('xstate').SnapshotFrom<invite.actor>} snapshot
322
+ * @param {string} invitorDeviceId
323
+ * @returns {Invite}
324
+ */
325
+ function toInvite(internal, snapshot, invitorDeviceId) {
326
+ const state = toStateString(snapshot.value)
327
+ if (state === 'error') {
328
+ return {
329
+ ...internal,
330
+ invitorDeviceId,
331
+ inviteId: internal.inviteId.toString('hex'),
332
+ projectInviteId: internal.projectInviteId.toString('hex'),
333
+ state,
334
+ error: snapshot.context.error || new Error('Unknown error'),
335
+ }
336
+ } else {
337
+ return {
338
+ ...internal,
339
+ invitorDeviceId,
340
+ inviteId: internal.inviteId.toString('hex'),
341
+ projectInviteId: internal.projectInviteId.toString('hex'),
342
+ state,
343
+ }
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Assert that a given event type can be sent to the state machine (will throw
349
+ * if there is no transition defined for this event type for the current state
350
+ * of the machine)
351
+ *
352
+ * @param {invite.actor} actor
353
+ * @param {import('xstate').EventFrom<invite.actor>} eventType
354
+ */
355
+ function assertCanSend(actor, eventType) {
356
+ const state = actor.getSnapshot()
357
+ assert(
358
+ state.can(eventType),
359
+ new InviteSendError(
360
+ `Cannot send ${JSON.stringify(eventType)} in state ${toStateString(
361
+ state.value
362
+ )}`
363
+ )
364
+ )
365
+ }
366
+
367
+ /**
368
+ * @template {import('xstate').StateValue} T
369
+ * @typedef {T extends string ? T : T extends import('xstate').StateValueMap ? keyof T : never} ExtractStateString
370
+ */
371
+
372
+ /**
373
+ * "Flatten" nested states into a top-level string, e.g. in our case the state
374
+ * `{ joining: 'awaitingDetails' }` will be flattened to `'joining'`.
375
+ *
376
+ * @template {import('xstate').StateValue} T
377
+ * @param {T} stateValue
378
+ * @returns {ExtractStateString<T>}
379
+ */
380
+ function toStateString(stateValue) {
381
+ if (typeof stateValue === 'string') {
382
+ // @ts-expect-error - typescript limitation
383
+ return stateValue
384
+ }
385
+ // @ts-expect-error - typescript limitation
386
+ return Object.keys(stateValue)[0]
387
+ }
@@ -0,0 +1,208 @@
1
+ import { setup, assign, fromPromise, assertEvent, raise } from 'xstate'
2
+ import { omit } from '../lib/omit.js'
3
+ import { InviteResponse_Decision } from '../generated/rpc.js'
4
+ import ensureError from 'ensure-error'
5
+ import { TimeoutError } from '../errors.js'
6
+
7
+ const RECEIVE_PROJECT_DETAILS_TIMEOUT_MS = 10_000
8
+ const ADD_PROJECT_TIMEOUT_MS = 10_000
9
+
10
+ /** @import { StringToTaggedUnion } from '../types.js' */
11
+ /** @import { ProjectJoinDetails } from '../generated/rpc.js' */
12
+ /**
13
+ * @internal
14
+ * @typedef {object} Context
15
+ * @property {null | Error} error
16
+ * @property {null | string} projectPublicId
17
+ */
18
+ /**
19
+ * @typedef {object} MachineSetupTypes
20
+ * @property {Context} context
21
+ * @property {{ projectPublicId: string | null }} output
22
+ * @property {StringToTaggedUnion<'ACCEPT_INVITE' | 'CANCEL_INVITE' | 'REJECT_INVITE' | 'ALREADY_IN_PROJECT' | 'ADDED_PROJECT' | 'PEER_DISCONNECTED'> | ({ type: 'RECEIVE_PROJECT_DETAILS' } & ProjectJoinDetails)} events
23
+ */
24
+
25
+ export const inviteStateMachine = setup({
26
+ types: /** @type {MachineSetupTypes} */ ({}),
27
+ actors: {
28
+ sendInviteResponse: fromPromise(
29
+ /**
30
+ * @param {{ input: { decision: InviteResponse_Decision }}} _opts
31
+ */
32
+ async (_opts) => {}
33
+ ),
34
+ addProject: fromPromise(
35
+ /**
36
+ * @param {{ input: ProjectJoinDetails }} _opts
37
+ * @returns {Promise<string>} The project public ID
38
+ */
39
+ async (_opts) => ''
40
+ ),
41
+ },
42
+ guards: {
43
+ isNotAlreadyJoiningOrInProject: () => true,
44
+ },
45
+ delays: {
46
+ receiveTimeout: RECEIVE_PROJECT_DETAILS_TIMEOUT_MS,
47
+ addProjectTimeout: ADD_PROJECT_TIMEOUT_MS,
48
+ },
49
+ }).createMachine({
50
+ id: 'invite',
51
+ context: {
52
+ error: null,
53
+ projectPublicId: null,
54
+ },
55
+ initial: 'pending',
56
+ states: {
57
+ pending: {
58
+ description: 'Pending invite awaiting response',
59
+ on: {
60
+ CANCEL_INVITE: { target: 'canceled' },
61
+ ACCEPT_INVITE: [
62
+ {
63
+ target: 'responding.accept',
64
+ guard: { type: 'isNotAlreadyJoiningOrInProject' },
65
+ },
66
+ {
67
+ actions: raise({ type: 'ALREADY_IN_PROJECT' }),
68
+ },
69
+ ],
70
+ ALREADY_IN_PROJECT: {
71
+ target: 'responding.already',
72
+ },
73
+ REJECT_INVITE: {
74
+ target: 'responding.reject',
75
+ },
76
+ },
77
+ },
78
+ responding: {
79
+ description: 'Responding to invite',
80
+ initial: 'default',
81
+ on: {
82
+ CANCEL_INVITE: { target: '#invite.canceled' },
83
+ },
84
+ states: {
85
+ default: {
86
+ always: {
87
+ target: '#invite.error',
88
+ actions: assign({ error: () => new TypeError('InvalidState') }),
89
+ },
90
+ },
91
+ accept: {
92
+ description: 'Sending accept response',
93
+ invoke: {
94
+ src: 'sendInviteResponse',
95
+ input: { decision: InviteResponse_Decision.ACCEPT },
96
+ onDone: '#invite.joining',
97
+ onError: '#invite.error',
98
+ },
99
+ on: {
100
+ // It's possible project details could be received before the send
101
+ // response promise resolves (e.g. somehow the peer receives the
102
+ // response and sends the project details before the response is
103
+ // confirmed as sent), so we accept project details at this point
104
+ RECEIVE_PROJECT_DETAILS: {
105
+ target: '#invite.joining.addingProject',
106
+ },
107
+ },
108
+ },
109
+ reject: {
110
+ description: 'Sending reject response',
111
+ invoke: {
112
+ src: 'sendInviteResponse',
113
+ input: { decision: InviteResponse_Decision.REJECT },
114
+ onDone: '#invite.rejected',
115
+ onError: '#invite.error',
116
+ },
117
+ },
118
+ already: {
119
+ description: 'Sending already response',
120
+ invoke: {
121
+ src: 'sendInviteResponse',
122
+ input: { decision: InviteResponse_Decision.ALREADY },
123
+ onDone: '#invite.respondedAlready',
124
+ onError: '#invite.error',
125
+ },
126
+ },
127
+ },
128
+ },
129
+ joining: {
130
+ initial: 'awaitingDetails',
131
+ description: 'Joining project from invite',
132
+ states: {
133
+ awaitingDetails: {
134
+ description: 'Waiting for project details',
135
+ on: {
136
+ RECEIVE_PROJECT_DETAILS: { target: 'addingProject' },
137
+ CANCEL_INVITE: { target: '#invite.canceled' },
138
+ },
139
+ after: {
140
+ receiveTimeout: {
141
+ target: '#invite.error',
142
+ actions: assign({
143
+ error: () =>
144
+ new TimeoutError('Timed out waiting for project details'),
145
+ }),
146
+ },
147
+ },
148
+ },
149
+ addingProject: {
150
+ description: 'Adding project from invite',
151
+ invoke: {
152
+ src: 'addProject',
153
+ input: ({ event }) => {
154
+ assertEvent(event, 'RECEIVE_PROJECT_DETAILS')
155
+ return omit(event, ['type'])
156
+ },
157
+ onDone: {
158
+ target: '#invite.joined',
159
+ actions: assign({
160
+ projectPublicId: ({ event }) => event.output,
161
+ }),
162
+ },
163
+ onError: '#invite.error',
164
+ },
165
+ after: {
166
+ addProjectTimeout: {
167
+ target: '#invite.error',
168
+ actions: assign({
169
+ error: () => new TimeoutError('Timed out adding project'),
170
+ }),
171
+ },
172
+ },
173
+ },
174
+ },
175
+ },
176
+ canceled: {
177
+ description: 'The invite has been canceled',
178
+ type: 'final',
179
+ },
180
+ rejected: {
181
+ description: 'Rejected invite',
182
+ type: 'final',
183
+ },
184
+ respondedAlready: {
185
+ description: 'Responded that already in project',
186
+ type: 'final',
187
+ },
188
+ joined: {
189
+ description: 'Successfully joined project',
190
+ type: 'final',
191
+ },
192
+ error: {
193
+ entry: assign({
194
+ error: ({ event, context }) =>
195
+ context.error ||
196
+ ensureError(
197
+ // @ts-expect-error - xstate types are incorrect, for internal events
198
+ // the error property can exist, and ensureError handles event.error
199
+ // being undefined.
200
+ event.error
201
+ ),
202
+ }),
203
+ type: 'final',
204
+ description: 'Error joining project',
205
+ },
206
+ },
207
+ output: ({ context }) => ({ projectPublicId: context.projectPublicId }),
208
+ })
@@ -169,7 +169,7 @@ class Peer {
169
169
  }
170
170
  /** @param {Invite} invite */
171
171
  sendInvite(invite) {
172
- this.#assertConnected()
172
+ this.#assertConnected('Peer disconnected before sending invite')
173
173
  const buf = Buffer.from(Invite.encode(invite).finish())
174
174
  const messageType = MESSAGE_TYPES.Invite
175
175
  this.#channel.messages[messageType].send(buf)
@@ -177,7 +177,7 @@ class Peer {
177
177
  }
178
178
  /** @param {InviteCancel} inviteCancel */
179
179
  sendInviteCancel(inviteCancel) {
180
- this.#assertConnected()
180
+ this.#assertConnected('Peer disconnected before sending invite cancel')
181
181
  const buf = Buffer.from(InviteCancel.encode(inviteCancel).finish())
182
182
  const messageType = MESSAGE_TYPES.InviteCancel
183
183
  this.#channel.messages[messageType].send(buf)
@@ -185,7 +185,7 @@ class Peer {
185
185
  }
186
186
  /** @param {InviteResponse} response */
187
187
  sendInviteResponse(response) {
188
- this.#assertConnected()
188
+ this.#assertConnected('Peer disconnected before sending invite response')
189
189
  const buf = Buffer.from(InviteResponse.encode(response).finish())
190
190
  const messageType = MESSAGE_TYPES.InviteResponse
191
191
  this.#channel.messages[messageType].send(buf)
@@ -193,7 +193,9 @@ class Peer {
193
193
  }
194
194
  /** @param {ProjectJoinDetails} details */
195
195
  sendProjectJoinDetails(details) {
196
- this.#assertConnected()
196
+ this.#assertConnected(
197
+ 'Peer disconnected before sending project join details'
198
+ )
197
199
  const buf = Buffer.from(ProjectJoinDetails.encode(details).finish())
198
200
  const messageType = MESSAGE_TYPES.ProjectJoinDetails
199
201
  this.#channel.messages[messageType].send(buf)
@@ -212,10 +214,11 @@ class Peer {
212
214
  this.#deviceType = deviceInfo.deviceType
213
215
  this.#log('received deviceInfo %o', deviceInfo)
214
216
  }
215
- #assertConnected() {
217
+ /** @param {string} [message] */
218
+ #assertConnected(message) {
216
219
  if (this.#state === 'connected' && !this.#channel.closed) return
217
220
  /* c8 ignore next */
218
- throw new PeerDisconnectedError() // TODO: report error - this should not happen
221
+ throw new PeerDisconnectedError(message) // TODO: report error - this should not happen
219
222
  }
220
223
  }
221
224
 
@@ -614,7 +617,7 @@ export { TimeoutError }
614
617
 
615
618
  export class UnknownPeerError extends Error {
616
619
  /** @param {string} [message] */
617
- constructor(message) {
620
+ constructor(message = 'UnknownPeerError') {
618
621
  super(message)
619
622
  this.name = 'UnknownPeerError'
620
623
  }
@@ -622,7 +625,7 @@ export class UnknownPeerError extends Error {
622
625
 
623
626
  export class PeerDisconnectedError extends Error {
624
627
  /** @param {string} [message] */
625
- constructor(message) {
628
+ constructor(message = 'Peer disconnected') {
626
629
  super(message)
627
630
  this.name = 'PeerDisconnectedError'
628
631
  }
@@ -630,7 +633,7 @@ export class PeerDisconnectedError extends Error {
630
633
 
631
634
  export class PeerFailedConnectionError extends Error {
632
635
  /** @param {string} [message] */
633
- constructor(message) {
636
+ constructor(message = 'PeerFailedConnectionError') {
634
637
  super(message)
635
638
  this.name = 'PeerFailedConnectionError'
636
639
  }
@@ -42,7 +42,7 @@ import IconServerPlugin from './fastify-plugins/icons.js'
42
42
  import { plugin as MapServerPlugin } from './fastify-plugins/maps.js'
43
43
  import { getFastifyServerAddress } from './fastify-plugins/utils.js'
44
44
  import { LocalPeers } from './local-peers.js'
45
- import { InviteApi } from './invite-api.js'
45
+ import { InviteApi } from './invite/invite-api.js'
46
46
  import { LocalDiscovery } from './discovery/local-discovery.js'
47
47
  import { Roles } from './roles.js'
48
48
  import { Logger } from './logger.js'