@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.
- package/dist/blob-store/entries-stream.d.ts.map +1 -1
- package/dist/blob-store/index.d.ts +27 -15
- package/dist/blob-store/index.d.ts.map +1 -1
- package/dist/blob-store/live-download.d.ts +107 -0
- package/dist/blob-store/live-download.d.ts.map +1 -0
- package/dist/capabilities.d.ts +121 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/core-manager/compat.d.ts +4 -0
- package/dist/core-manager/compat.d.ts.map +1 -0
- package/dist/core-manager/index.d.ts +4 -4
- package/dist/core-manager/index.d.ts.map +1 -1
- package/dist/discovery/dns-sd.d.ts +54 -0
- package/dist/discovery/dns-sd.d.ts.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -1
- 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/generated/extensions.d.ts +7 -0
- package/dist/generated/extensions.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/invite/invite-api.d.ts +112 -0
- package/dist/invite/invite-api.d.ts.map +1 -0
- package/dist/invite/invite-state-machine.d.ts +510 -0
- package/dist/invite/invite-state-machine.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.map +1 -1
- package/dist/mapeo-manager.d.ts +1 -1
- package/dist/mapeo-manager.d.ts.map +1 -1
- package/dist/mapeo-project.d.ts.map +1 -1
- package/dist/media-server.d.ts +36 -0
- package/dist/media-server.d.ts.map +1 -0
- package/dist/member-api.d.ts.map +1 -1
- package/dist/server/ws-core-replicator.d.ts +6 -0
- package/dist/server/ws-core-replicator.d.ts.map +1 -0
- package/dist/sync/core-sync-state.d.ts +14 -6
- package/dist/sync/core-sync-state.d.ts.map +1 -1
- package/dist/sync/namespace-sync-state.d.ts +3 -13
- package/dist/sync/namespace-sync-state.d.ts.map +1 -1
- package/dist/sync/sync-api.d.ts +17 -25
- package/dist/sync/sync-api.d.ts.map +1 -1
- package/dist/sync/sync-state.d.ts +3 -13
- package/dist/sync/sync-state.d.ts.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +2 -2
- package/dist/utils.d.ts.map +1 -1
- package/package.json +5 -3
- package/src/blob-store/entries-stream.js +43 -18
- package/src/blob-store/index.js +161 -19
- package/src/core-manager/index.js +14 -7
- package/src/errors.js +33 -0
- package/src/generated/extensions.d.ts +7 -0
- package/src/generated/extensions.js +12 -2
- package/src/generated/extensions.ts +19 -1
- package/src/invite/StateDiagram.md +47 -0
- package/src/invite/invite-api.js +387 -0
- package/src/invite/invite-state-machine.js +208 -0
- package/src/local-peers.js +12 -9
- package/src/mapeo-manager.js +1 -1
- package/src/mapeo-project.js +7 -76
- package/src/member-api.js +5 -4
- package/src/sync/core-sync-state.js +41 -14
- package/src/sync/namespace-sync-state.js +25 -22
- package/src/sync/sync-api.js +12 -43
- package/src/sync/sync-state.js +9 -19
- package/src/types.ts +7 -1
- package/src/utils.js +8 -3
- 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
|
+
})
|
package/src/local-peers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/mapeo-manager.js
CHANGED
|
@@ -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'
|