@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,450 @@
1
+ import { TypedEmitter } from 'tiny-typed-emitter'
2
+ import { pEvent } from 'p-event'
3
+ import { InviteResponse_Decision } from './generated/rpc.js'
4
+ import { assert, keyToId, noop } from './utils.js'
5
+ import HashMap from './lib/hashmap.js'
6
+ import timingSafeEqual from './lib/timing-safe-equal.js'
7
+ import { Logger } from './logger.js'
8
+ /** @import { MapBuffers } from './types.js' */
9
+ /**
10
+ * @import {
11
+ * Invite as InviteRpcMessage,
12
+ * InviteCancel,
13
+ * ProjectJoinDetails
14
+ * } from './generated/rpc.js'
15
+ */
16
+
17
+ // There are three slightly different invite types:
18
+ //
19
+ // - InviteRpcMessage comes from the protobuf.
20
+ // - InviteInternal adds a locally-generated receive timestamp.
21
+ // - Invite is the externally-facing type.
22
+
23
+ /**
24
+ * @internal
25
+ * @typedef {InviteRpcMessage & { receivedAt: number }} InviteInternal
26
+ */
27
+
28
+ /** @typedef {MapBuffers<InviteInternal>} Invite */
29
+
30
+ /**
31
+ * @typedef {(
32
+ * 'accepted' |
33
+ * 'rejected' |
34
+ * 'canceled' |
35
+ * 'accepted other' |
36
+ * 'connection error' |
37
+ * 'internal error'
38
+ * )} InviteRemovalReason
39
+ */
40
+
41
+ /**
42
+ * Manage pending invite state.
43
+ */
44
+ class PendingInvites {
45
+ /**
46
+ * @internal
47
+ * @typedef {object} PendingInvite
48
+ * @prop {string} peerId
49
+ * @prop {InviteInternal} invite
50
+ * @prop {boolean} isAccepting
51
+ */
52
+
53
+ /** @type {HashMap<Buffer, PendingInvite>} */
54
+ #byInviteId = new HashMap(keyToId)
55
+
56
+ /**
57
+ * @returns {Iterable<PendingInvite>} the pending invites, in insertion order
58
+ */
59
+ invites() {
60
+ return this.#byInviteId.values()
61
+ }
62
+
63
+ /**
64
+ * @param {PendingInvite} pendingInvite
65
+ * @throws if adding a duplicate invite ID
66
+ * @returns {void}
67
+ */
68
+ add(pendingInvite) {
69
+ const {
70
+ invite: { inviteId },
71
+ } = pendingInvite
72
+ assert(!this.#byInviteId.has(inviteId), 'Added duplicate invite')
73
+ this.#byInviteId.set(inviteId, pendingInvite)
74
+ }
75
+
76
+ /**
77
+ * @param {Buffer} inviteId
78
+ * @returns {void}
79
+ */
80
+ markAccepting(inviteId) {
81
+ const pendingInvite = this.#byInviteId.get(inviteId)
82
+ assert(
83
+ !!pendingInvite,
84
+ `Couldn't find invite for ${inviteId.toString('hex')}`
85
+ )
86
+ this.#byInviteId.set(inviteId, { ...pendingInvite, isAccepting: true })
87
+ }
88
+
89
+ /**
90
+ * @param {Buffer} inviteId
91
+ * @returns {boolean}
92
+ */
93
+ hasInviteId(inviteId) {
94
+ return this.#byInviteId.has(inviteId)
95
+ }
96
+
97
+ /**
98
+ * @param {Readonly<Buffer>} projectInviteId
99
+ * @returns {boolean}
100
+ */
101
+ isAcceptingForProject(projectInviteId) {
102
+ for (const { invite, isAccepting } of this.invites()) {
103
+ if (isAccepting && invite.projectInviteId.equals(projectInviteId)) {
104
+ return true
105
+ }
106
+ }
107
+ return false
108
+ }
109
+
110
+ /**
111
+ * @param {Buffer} inviteId
112
+ * @returns {undefined | PendingInvite}
113
+ */
114
+ getByInviteId(inviteId) {
115
+ return this.#byInviteId.get(inviteId)
116
+ }
117
+
118
+ /**
119
+ * @param {Buffer} inviteId
120
+ * @returns {boolean} `true` if an invite existed and was deleted, `false` otherwise
121
+ */
122
+ deleteByInviteId(inviteId) {
123
+ return this.#byInviteId.delete(inviteId)
124
+ }
125
+
126
+ /**
127
+ * @param {Readonly<Buffer>} projectInviteId
128
+ * @returns {PendingInvite[]} the pending invites that were deleted
129
+ */
130
+ deleteByProjectInviteId(projectInviteId) {
131
+ /** @type {PendingInvite[]} */
132
+ const result = []
133
+
134
+ for (const pendingInvite of this.invites()) {
135
+ if (pendingInvite.invite.projectInviteId.equals(projectInviteId)) {
136
+ result.push(pendingInvite)
137
+ }
138
+ }
139
+
140
+ for (const { invite } of result) this.deleteByInviteId(invite.inviteId)
141
+
142
+ return result
143
+ }
144
+ }
145
+
146
+ /**
147
+ * @typedef {Object} InviteApiEvents
148
+ * @property {(invite: Invite) => void} invite-received
149
+ * @property {(invite: Invite, removalReason: InviteRemovalReason) => void} invite-removed
150
+ */
151
+
152
+ /**
153
+ * @extends {TypedEmitter<InviteApiEvents>}
154
+ */
155
+ export class InviteApi extends TypedEmitter {
156
+ #getProjectByInviteId
157
+ #addProject
158
+ #pendingInvites = new PendingInvites()
159
+ #l
160
+
161
+ /**
162
+ * @param {Object} options
163
+ * @param {import('./local-peers.js').LocalPeers} options.rpc
164
+ * @param {object} options.queries
165
+ * @param {(projectInviteId: Readonly<Buffer>) => undefined | { projectPublicId: string }} options.queries.getProjectByInviteId
166
+ * @param {(projectDetails: Pick<ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string }) => Promise<string>} options.queries.addProject
167
+ * @param {Logger} [options.logger]
168
+ */
169
+ constructor({ rpc, queries, logger }) {
170
+ super()
171
+
172
+ this.#l = Logger.create('InviteApi', logger)
173
+
174
+ this.rpc = rpc
175
+ this.#getProjectByInviteId = queries.getProjectByInviteId
176
+ this.#addProject = queries.addProject
177
+
178
+ this.rpc.on('invite', (...args) => {
179
+ try {
180
+ this.#handleInviteRpcMessage(...args)
181
+ } catch (err) {
182
+ console.error('Error handling invite', err)
183
+ }
184
+ })
185
+
186
+ this.rpc.on('invite-cancel', (_peerId, inviteCancel) => {
187
+ try {
188
+ this.#handleInviteCancel(inviteCancel)
189
+ } catch (err) {
190
+ console.error('Error handling invite cancel', err)
191
+ }
192
+ })
193
+ }
194
+
195
+ /**
196
+ * @param {string} peerId
197
+ * @param {InviteRpcMessage} inviteRpcMessage
198
+ */
199
+ #handleInviteRpcMessage(peerId, inviteRpcMessage) {
200
+ const invite = { ...inviteRpcMessage, receivedAt: Date.now() }
201
+
202
+ this.#l.log('Received invite %h from %S', invite.inviteId, peerId)
203
+
204
+ const isAlreadyMember = Boolean(
205
+ this.#getProjectByInviteId(invite.projectInviteId)
206
+ )
207
+ if (isAlreadyMember) {
208
+ this.#l.log('Invite %h: already in project', invite.inviteId)
209
+ this.rpc
210
+ .sendInviteResponse(peerId, {
211
+ decision: InviteResponse_Decision.ALREADY,
212
+ inviteId: invite.inviteId,
213
+ })
214
+ .catch(noop)
215
+ return
216
+ }
217
+
218
+ const hasAlreadyReceivedThisInvite = this.#pendingInvites.hasInviteId(
219
+ invite.inviteId
220
+ )
221
+ if (hasAlreadyReceivedThisInvite) {
222
+ this.#l.log('Invite %h: already received this invite', invite.inviteId)
223
+ return
224
+ }
225
+
226
+ this.#pendingInvites.add({ peerId, invite, isAccepting: false })
227
+ this.emit('invite-received', internalToExternal(invite))
228
+ }
229
+
230
+ /**
231
+ * @param {InviteCancel} inviteCancel
232
+ */
233
+ #handleInviteCancel(inviteCancel) {
234
+ const { inviteId } = inviteCancel
235
+
236
+ this.#l.log('Received invite cancel for invite ID %h', inviteId)
237
+
238
+ const pendingInvite = this.#pendingInvites.getByInviteId(inviteId)
239
+ if (!pendingInvite) {
240
+ this.#l.log(
241
+ 'Received invite cancel for %h but no such invite exists',
242
+ inviteId
243
+ )
244
+ return
245
+ }
246
+ const { invite, isAccepting } = pendingInvite
247
+
248
+ if (isAccepting) {
249
+ this.#l.log(
250
+ "Received invite cancel for %h but we're already accepting",
251
+ inviteId
252
+ )
253
+ return
254
+ }
255
+
256
+ this.#pendingInvites.deleteByInviteId(inviteId)
257
+ this.emit('invite-removed', internalToExternal(invite), 'canceled')
258
+ }
259
+
260
+ /**
261
+ * @returns {Array<Invite>}
262
+ */
263
+ getPending() {
264
+ return [...this.#pendingInvites.invites()].map(({ invite }) =>
265
+ internalToExternal(invite)
266
+ )
267
+ }
268
+
269
+ /**
270
+ * Attempt to accept the invite.
271
+ *
272
+ * This can fail if the invitor has canceled the invite or if you cannot
273
+ * connect to the invitor's device.
274
+ *
275
+ * If the invite is accepted and you had other invites to the same project,
276
+ * those invites are removed, and the invitors are told that you're already
277
+ * part of this project.
278
+ *
279
+ * @param {Pick<Invite, 'inviteId'>} invite
280
+ * @returns {Promise<string>}
281
+ */
282
+ async accept({ inviteId: inviteIdString }) {
283
+ const inviteId = Buffer.from(inviteIdString, 'hex')
284
+
285
+ const pendingInvite = this.#pendingInvites.getByInviteId(inviteId)
286
+ if (!pendingInvite) {
287
+ throw new Error(`Cannot find invite ID ${inviteIdString}`)
288
+ }
289
+
290
+ const { peerId, invite } = pendingInvite
291
+ const { projectName, projectInviteId } = invite
292
+
293
+ /** @param {InviteRemovalReason} removalReason */
294
+ const removePendingInvite = (removalReason) => {
295
+ const didDelete = this.#pendingInvites.deleteByInviteId(inviteId)
296
+ if (didDelete) {
297
+ this.emit('invite-removed', internalToExternal(invite), removalReason)
298
+ }
299
+ }
300
+
301
+ // This is probably impossible in the UI, but it's theoretically possible
302
+ // to join a project while an invite is pending, so we need to check this.
303
+ const existingProject = this.#getProjectByInviteId(projectInviteId)
304
+ if (existingProject) {
305
+ this.#l.log(
306
+ "Went to accept invite %h but we're already in the project",
307
+ inviteId
308
+ )
309
+ const pendingInvitesDeleted =
310
+ this.#pendingInvites.deleteByProjectInviteId(projectInviteId)
311
+ for (const pendingInvite of pendingInvitesDeleted) {
312
+ this.rpc
313
+ .sendInviteResponse(pendingInvite.peerId, {
314
+ decision: InviteResponse_Decision.ALREADY,
315
+ inviteId: pendingInvite.invite.inviteId,
316
+ })
317
+ .catch(noop)
318
+ this.emit(
319
+ 'invite-removed',
320
+ internalToExternal(pendingInvite.invite),
321
+ 'accepted'
322
+ )
323
+ }
324
+ return existingProject.projectPublicId
325
+ }
326
+
327
+ assert(
328
+ !this.#pendingInvites.isAcceptingForProject(projectInviteId),
329
+ `Cannot double-accept invite for project ${projectInviteId
330
+ .toString('hex')
331
+ .slice(0, 7)}`
332
+ )
333
+ this.#pendingInvites.markAccepting(inviteId)
334
+
335
+ const projectDetailsAbortController = new AbortController()
336
+
337
+ const projectDetailsPromise =
338
+ /** @type {typeof pEvent<'got-project-details', [string, ProjectJoinDetails]>} */ (
339
+ pEvent
340
+ )(this.rpc, 'got-project-details', {
341
+ multiArgs: true,
342
+ filter: ([projectDetailsPeerId, details]) =>
343
+ // This peer ID check is probably superfluous because the invite ID
344
+ // should be unguessable, but might be useful if someone forwards an
345
+ // invite message (or if there's an unforeseen bug).
346
+ timingSafeEqual(projectDetailsPeerId, peerId) &&
347
+ timingSafeEqual(inviteId, details.inviteId),
348
+ signal: projectDetailsAbortController.signal,
349
+ })
350
+ .then((args) => args?.[1])
351
+ .catch(noop)
352
+
353
+ this.#l.log('Sending accept response for invite %h', inviteId)
354
+
355
+ try {
356
+ await this.rpc.sendInviteResponse(peerId, {
357
+ decision: InviteResponse_Decision.ACCEPT,
358
+ inviteId,
359
+ })
360
+ } catch (e) {
361
+ projectDetailsAbortController.abort()
362
+ removePendingInvite('connection error')
363
+ throw new Error('Could not accept invite: Peer disconnected')
364
+ }
365
+
366
+ /** @type {string} */ let projectPublicId
367
+
368
+ try {
369
+ const details = await projectDetailsPromise
370
+ assert(details, 'Expected project details')
371
+ projectPublicId = await this.#addProject({ ...details, projectName })
372
+ } catch (e) {
373
+ removePendingInvite('internal error')
374
+ throw new Error('Failed to join project')
375
+ }
376
+
377
+ const pendingInvitesDeleted =
378
+ this.#pendingInvites.deleteByProjectInviteId(projectInviteId)
379
+
380
+ for (const pendingInvite of pendingInvitesDeleted) {
381
+ const isPendingInviteWeJustAccepted =
382
+ // Unlike the above, these don't need to be timing-safe, because
383
+ // it's unlikely this method is vulnerable to timing attacks.
384
+ peerId === pendingInvite.peerId &&
385
+ inviteId.equals(pendingInvite.invite.inviteId)
386
+ if (isPendingInviteWeJustAccepted) continue
387
+
388
+ this.#l.log(
389
+ 'Sending "already" response for invite %h to %S',
390
+ inviteId,
391
+ pendingInvite.peerId
392
+ )
393
+
394
+ this.rpc
395
+ .sendInviteResponse(pendingInvite.peerId, {
396
+ decision: InviteResponse_Decision.ALREADY,
397
+ inviteId: pendingInvite.invite.inviteId,
398
+ })
399
+ .catch(noop)
400
+ this.emit(
401
+ 'invite-removed',
402
+ internalToExternal(pendingInvite.invite),
403
+ 'accepted other'
404
+ )
405
+ }
406
+
407
+ this.emit('invite-removed', internalToExternal(invite), 'accepted')
408
+
409
+ return projectPublicId
410
+ }
411
+
412
+ /**
413
+ * @param {Pick<Invite, 'inviteId'>} invite
414
+ * @returns {void}
415
+ */
416
+ reject({ inviteId: inviteIdString }) {
417
+ const inviteId = Buffer.from(inviteIdString, 'hex')
418
+
419
+ const pendingInvite = this.#pendingInvites.getByInviteId(inviteId)
420
+ assert(!!pendingInvite, `Cannot find invite ${inviteId}`)
421
+
422
+ const { peerId, invite, isAccepting } = pendingInvite
423
+
424
+ assert(!isAccepting, `Cannot reject ${inviteIdString}`)
425
+
426
+ this.#l.log('Rejecting invite %h', inviteId)
427
+
428
+ this.rpc
429
+ .sendInviteResponse(peerId, {
430
+ decision: InviteResponse_Decision.REJECT,
431
+ inviteId: invite.inviteId,
432
+ })
433
+ .catch(noop)
434
+
435
+ this.#pendingInvites.deleteByInviteId(inviteId)
436
+ this.emit('invite-removed', internalToExternal(invite), 'rejected')
437
+ }
438
+ }
439
+
440
+ /**
441
+ * @param {InviteInternal} internal
442
+ * @returns {Invite}
443
+ */
444
+ function internalToExternal(internal) {
445
+ return {
446
+ ...internal,
447
+ inviteId: internal.inviteId.toString('hex'),
448
+ projectInviteId: internal.projectInviteId.toString('hex'),
449
+ }
450
+ }
@@ -0,0 +1,91 @@
1
+ /** @typedef {string | number | bigint | boolean | undefined | symbol | null} Primitive */
2
+
3
+ /**
4
+ * `Map` uses same-value-zero equality for keys, which makes it more difficult
5
+ * to use reference types like buffers.
6
+ *
7
+ * `HashMap` is very similar to `Map`, but accepts a hash function for keys.
8
+ * This function should return a primitive, such as a number or string, which
9
+ * will be used as the key.
10
+ *
11
+ * It doesn't contain all the functionality of `Map` because we don't need it,
12
+ * but it should be fairly easy to update as needed.
13
+ *
14
+ * @template K
15
+ * @template {unknown} V
16
+ * @example
17
+ * const join = (arr) => arr.join(' ')
18
+ *
19
+ * const map = new HashMap(join)
20
+ *
21
+ * map.set([1, 2], 3)
22
+ * map.get([1, 2])
23
+ * // => 3
24
+ */
25
+ export default class HashMap {
26
+ #hash
27
+
28
+ /** @type {Map<Primitive, V>} */
29
+ #realMap = new Map()
30
+
31
+ /**
32
+ * @param {(key: K) => Primitive} hash
33
+ * @param {Iterable<[K, V]>} [iterable=[]]
34
+ */
35
+ constructor(hash, iterable = []) {
36
+ this.#hash = hash
37
+ for (const [key, value] of iterable) this.set(key, value)
38
+ }
39
+
40
+ /**
41
+ * @returns {number}
42
+ */
43
+ get size() {
44
+ return this.#realMap.size
45
+ }
46
+
47
+ /**
48
+ * @param {K} key The key to remove.
49
+ * @returns {boolean} `true` if the key was present and removed, `false` otherwise.
50
+ */
51
+ delete(key) {
52
+ const realKey = this.#hash(key)
53
+ return this.#realMap.delete(realKey)
54
+ }
55
+
56
+ /**
57
+ * @param {K} key The key to look up.
58
+ * @returns {undefined | V} The element associated with `key`, or `undefined` if it's not present.
59
+ */
60
+ get(key) {
61
+ const realKey = this.#hash(key)
62
+ return this.#realMap.get(realKey)
63
+ }
64
+
65
+ /**
66
+ * @param {K} key The key to look up.
67
+ * @returns {boolean} `true` if `key` is present in the map, `false` otherwise.
68
+ */
69
+ has(key) {
70
+ const realKey = this.#hash(key)
71
+ return this.#realMap.has(realKey)
72
+ }
73
+
74
+ /**
75
+ * @param {K} key The key to update.
76
+ * @param {V} value The value to add at `key`.
77
+ * @returns {this} The map.
78
+ */
79
+ set(key, value) {
80
+ const realKey = this.#hash(key)
81
+ this.#realMap.set(realKey, value)
82
+ return this
83
+ }
84
+
85
+ /**
86
+ * @returns {IterableIterator<V>}
87
+ */
88
+ values() {
89
+ return this.#realMap.values()
90
+ }
91
+ }
@@ -0,0 +1,18 @@
1
+ import { assert } from '../utils.js'
2
+
3
+ /**
4
+ * @param {import('hypercore')<'binary', any>} core Core to unreplicate. Must be ready.
5
+ * @param {import('protomux')} protomux
6
+ */
7
+ export function unreplicate(core, protomux) {
8
+ assert(core.discoveryKey, 'Core should have a discovery key')
9
+ protomux.unpair({
10
+ protocol: 'hypercore/alpha',
11
+ id: core.discoveryKey,
12
+ })
13
+ for (const channel of protomux) {
14
+ if (channel.protocol !== 'hypercore/alpha') continue
15
+ if (!channel.id.equals(core.discoveryKey)) continue
16
+ channel.close()
17
+ }
18
+ }
@@ -0,0 +1,37 @@
1
+ /** @import { Duplex as NodeDuplex } from 'node:stream' */
2
+ /** @import { Duplex as StreamxDuplex } from 'streamx' */
3
+ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */
4
+
5
+ /**
6
+ * @internal
7
+ * @typedef {NodeDuplex | StreamxDuplex} RawStream
8
+ */
9
+
10
+ /**
11
+ * @template {RawStream} [T=RawStream]
12
+ * @typedef {NoiseSecretStream<T> & { destroyed: true }} DestroyedNoiseStream
13
+ */
14
+
15
+ /**
16
+ * @template {RawStream} [T=RawStream]
17
+ * @typedef {NoiseSecretStream<T> & {
18
+ * publicKey: Buffer,
19
+ * remotePublicKey: Buffer,
20
+ * handshake: Buffer,
21
+ * destroyed: false
22
+ * }} OpenedNoiseStream
23
+ */
24
+
25
+ /**
26
+ * Utility to await a NoiseSecretStream to open, that returns a stream with the
27
+ * correct types for publicKey and remotePublicKey (which can be null before
28
+ * stream is opened)
29
+ *
30
+ * @template {RawStream} T
31
+ * @param {NoiseSecretStream<T>} stream
32
+ * @returns {Promise<OpenedNoiseStream<T> | DestroyedNoiseStream<T>>}
33
+ */
34
+ export async function openedNoiseSecretStream(stream) {
35
+ await stream.opened
36
+ return /** @type {OpenedNoiseStream<T> | DestroyedNoiseStream<T>} */ (stream)
37
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Ponyfill of `AbortSignal.any()`.
3
+ *
4
+ * [0]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static
5
+ *
6
+ * @param {Iterable<AbortSignal>} iterable
7
+ * @returns {AbortSignal}
8
+ */
9
+ export function abortSignalAny(iterable) {
10
+ for (const signal of iterable) {
11
+ if (signal.aborted) return AbortSignal.abort(signal.reason)
12
+ }
13
+
14
+ /** @type {Array<() => unknown>} */
15
+ const listeners = []
16
+ const controller = new AbortController()
17
+
18
+ for (const signal of iterable) {
19
+ const listener = () => controller.abort(signal.reason)
20
+ signal.addEventListener('abort', listener)
21
+ listeners.push(listener)
22
+ }
23
+
24
+ return controller.signal
25
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Returns `true` if the string is empty or only contains whitespace, `false` otherwise.
3
+ *
4
+ * @param {string} str
5
+ * @returns {boolean}
6
+ */
7
+ export const isBlank = (str) => !str.trim()
@@ -0,0 +1,34 @@
1
+ import * as crypto from 'node:crypto'
2
+
3
+ /**
4
+ * @param {string | NodeJS.ArrayBufferView} value
5
+ * @returns {NodeJS.ArrayBufferView}
6
+ */
7
+ const bufferify = (value) =>
8
+ // We use UTF-16 because it's the only supported encoding that doesn't
9
+ // touch surrogate pairs. See [this post][0] for more details.
10
+ //
11
+ // [0]: https://evanhahn.com/crypto-timingsafeequal-with-strings/
12
+ typeof value === 'string' ? Buffer.from(value, 'utf16le') : value
13
+
14
+ /**
15
+ * Compare two values in constant time.
16
+ *
17
+ * Useful when you want to avoid leaking data.
18
+ *
19
+ * Like `crypto.timingSafeEqual`, but works with strings and doesn't throw if
20
+ * lengths differ.
21
+ *
22
+ * @template {string | NodeJS.ArrayBufferView} T
23
+ * @param {T} a
24
+ * @param {T} b
25
+ * @returns {boolean}
26
+ */
27
+ export default function timingSafeEqual(a, b) {
28
+ const bufferA = bufferify(a)
29
+ const bufferB = bufferify(b)
30
+ return (
31
+ bufferA.byteLength === bufferB.byteLength &&
32
+ crypto.timingSafeEqual(bufferA, bufferB)
33
+ )
34
+ }