@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,358 @@
1
+ import { validate } from '@comapeo/schema'
2
+ import { getTableConfig } from 'drizzle-orm/sqlite-core'
3
+ import { eq, inArray, sql } from 'drizzle-orm'
4
+ import { randomBytes } from 'node:crypto'
5
+ import { noop, deNullify } from '../utils.js'
6
+ import { NotFoundError } from '../errors.js'
7
+ import { TypedEmitter } from 'tiny-typed-emitter'
8
+ import { parse as parseBCP47 } from 'bcp-47'
9
+ import { setProperty, getProperty } from 'dot-prop'
10
+ /** @import { MapeoDoc, MapeoValue } from '@comapeo/schema' */
11
+ /** @import { MapeoDocMap, MapeoValueMap } from '../types.js' */
12
+ /** @import { DataStore } from '../datastore/index.js' */
13
+
14
+ /**
15
+ * @typedef {`${MapeoDoc['schemaName']}Table`} MapeoDocTableName
16
+ */
17
+ /**
18
+ * @template T
19
+ * @typedef {T[(keyof T) & MapeoDocTableName]} GetMapeoDocTables
20
+ */
21
+ /**
22
+ * Union of Drizzle schema tables that correspond to MapeoDoc types (e.g. excluding backlink tables and other utility tables)
23
+ * @typedef {GetMapeoDocTables<typeof import('../schema/project.js')> | GetMapeoDocTables<typeof import('../schema/client.js')>} MapeoDocTables
24
+ */
25
+ /**
26
+ * @typedef {{ [K in MapeoDocTables['_']['name']]: Extract<MapeoDocTables, { _: { name: K }}> }} MapeoDocTablesMap
27
+ */
28
+ /**
29
+ * @template T
30
+ * @template {keyof any} K
31
+ * @typedef {T extends any ? Omit<T, K> : never} OmitUnion
32
+ */
33
+ /**
34
+ * @template {MapeoDoc} TDoc
35
+ * @typedef {object} DataTypeEvents
36
+ * @property {(docs: TDoc[]) => void} updated-docs
37
+ */
38
+
39
+ function generateId() {
40
+ return randomBytes(32).toString('hex')
41
+ }
42
+ function generateDate() {
43
+ return new Date().toISOString()
44
+ }
45
+ export const kCreateWithDocId = Symbol('kCreateWithDocId')
46
+ export const kSelect = Symbol('select')
47
+ export const kTable = Symbol('table')
48
+ export const kDataStore = Symbol('dataStore')
49
+
50
+ /**
51
+ * @template {DataStore} TDataStore
52
+ * @template {TDataStore['schemas'][number]} TSchemaName
53
+ * @template {MapeoDocTablesMap[TSchemaName]} TTable
54
+ * @template {Exclude<MapeoDocMap[TSchemaName], { schemaName: 'coreOwnership' }>} TDoc
55
+ * @template {Exclude<MapeoValueMap[TSchemaName], { schemaName: 'coreOwnership' }>} TValue
56
+ * @extends {TypedEmitter<DataTypeEvents<TDoc> & import('../types.js').DefaultEmitterEvents<DataTypeEvents<TDoc>>>}
57
+ */
58
+ export class DataType extends TypedEmitter {
59
+ #dataStore
60
+ #table
61
+ #schemaName
62
+ #sql
63
+ #db
64
+ #getTranslations
65
+
66
+ /**
67
+ *
68
+ * @param {object} opts
69
+ * @param {TTable} opts.table
70
+ * @param {TDataStore} opts.dataStore
71
+ * @param {import('drizzle-orm/better-sqlite3').BetterSQLite3Database} opts.db
72
+ * @param {import('../translation-api.js').default['get']} opts.getTranslations
73
+ */
74
+ constructor({ dataStore, table, db, getTranslations }) {
75
+ super()
76
+ this.#dataStore = dataStore
77
+ this.#table = table
78
+ this.#schemaName = /** @type {TSchemaName} */ (getTableConfig(table).name)
79
+ this.#db = db
80
+ this.#getTranslations = getTranslations
81
+ this.#sql = {
82
+ getByDocId: db
83
+ .select()
84
+ .from(table)
85
+ .where(eq(table.docId, sql.placeholder('docId')))
86
+ .prepare(),
87
+ getMany: db
88
+ .select()
89
+ .from(table)
90
+ .where(eq(table.deleted, false))
91
+ .prepare(),
92
+ getManyWithDeleted: db.select().from(table).prepare(),
93
+ }
94
+ this.on('newListener', (eventName) => {
95
+ if (eventName !== 'updated-docs') return
96
+ if (this.listenerCount('updated-docs') > 1) return
97
+ if (this.#schemaName === 'projectSettings') return
98
+ // Avoid adding a listener to the dataStore unless we need to (e.g. this has a listener attached), for performance reasons.
99
+ this.#dataStore.on(this.#schemaName, this.#handleDataStoreUpdate)
100
+ })
101
+ this.on('removeListener', (eventName) => {
102
+ if (eventName !== 'updated-docs') return
103
+ if (this.listenerCount('updated-docs') > 0) return
104
+ if (this.#schemaName === 'projectSettings') return
105
+ this.#dataStore.off(this.#schemaName, this.#handleDataStoreUpdate)
106
+ })
107
+ }
108
+
109
+ get [kTable]() {
110
+ return this.#table
111
+ }
112
+
113
+ get schemaName() {
114
+ return this.#schemaName
115
+ }
116
+
117
+ get namespace() {
118
+ return this.#dataStore.namespace
119
+ }
120
+
121
+ get [kDataStore]() {
122
+ return this.#dataStore
123
+ }
124
+
125
+ /**
126
+ * @template {import('type-fest').Exact<TValue, T>} T
127
+ * @param {T} value
128
+ */
129
+ async create(value) {
130
+ const docId = generateId()
131
+ // @ts-expect-error - can't figure this one out, types in index.d.ts override this
132
+ return this[kCreateWithDocId](docId, value, { checkExisting: false })
133
+ }
134
+
135
+ /**
136
+ * @param {string} docId
137
+ * @param {TValue | import('../types.js').CoreOwnershipWithSignaturesValue} value
138
+ * @param {{ checkExisting?: boolean }} [opts] - only used internally to skip the checkExisting check when creating a document with a random ID (collisions should be too small probability to be worth checking for)
139
+ */
140
+ async [kCreateWithDocId](docId, value, { checkExisting = true } = {}) {
141
+ if (!validate(this.#schemaName, value)) {
142
+ // TODO: pass through errors from validate functions
143
+ throw new Error('Invalid value ' + value)
144
+ }
145
+ if (checkExisting) {
146
+ const existing = await this.getByDocId(docId).catch(noop)
147
+ if (existing) {
148
+ throw new Error('Doc with docId ' + docId + ' already exists')
149
+ }
150
+ }
151
+ const nowDateString = generateDate()
152
+
153
+ /** @type {Parameters<typeof DataStore.prototype.write>[0]} */
154
+ const doc = {
155
+ ...value,
156
+ docId,
157
+ createdAt: nowDateString,
158
+ updatedAt: nowDateString,
159
+ deleted: false,
160
+ links: [],
161
+ }
162
+ await this.#dataStore.write(doc)
163
+ return this.getByDocId(doc.docId)
164
+ }
165
+
166
+ /**
167
+ * @param {string} docId
168
+ * @param {{ lang?: string }} [opts]
169
+ */
170
+ async getByDocId(docId, { lang } = {}) {
171
+ await this.#dataStore.indexer.idle()
172
+ const result = /** @type {undefined | MapeoDoc} */ (
173
+ this.#sql.getByDocId.get({ docId })
174
+ )
175
+ if (!result) throw new NotFoundError()
176
+ return this.#translate(deNullify(result), { lang })
177
+ }
178
+
179
+ /**
180
+ * @param {string} versionId
181
+ * @param {{ lang?: string }} [opts]
182
+ */
183
+ async getByVersionId(versionId, { lang } = {}) {
184
+ const result = await this.#dataStore.read(versionId)
185
+ return this.#translate(result, { lang })
186
+ }
187
+
188
+ /**
189
+ * @param {MapeoDoc} doc
190
+ * @param {{ lang?: string }} [opts]
191
+ */
192
+ async #translate(doc, { lang } = {}) {
193
+ if (!lang) return doc
194
+
195
+ const { language, region } = parseBCP47(lang)
196
+ if (!language) return doc
197
+ const translatedDoc = JSON.parse(JSON.stringify(doc))
198
+
199
+ const value = {
200
+ languageCode: language,
201
+ docRef: {
202
+ docId: translatedDoc.docId,
203
+ versionId: translatedDoc.versionId,
204
+ },
205
+ docRefType: translatedDoc.schemaName,
206
+ regionCode: region !== null ? region : undefined,
207
+ }
208
+ let translations = await this.#getTranslations(value)
209
+ // if passing a region code returns no matches,
210
+ // fallback to matching only languageCode
211
+ if (translations.length === 0 && value.regionCode) {
212
+ value.regionCode = undefined
213
+ translations = await this.#getTranslations(value)
214
+ }
215
+
216
+ for (const translation of translations) {
217
+ if (typeof getProperty(doc, translation.propertyRef) === 'string') {
218
+ setProperty(doc, translation.propertyRef, translation.message)
219
+ }
220
+ }
221
+ return doc
222
+ }
223
+
224
+ /** @param {{ includeDeleted?: boolean, lang?: string }} [opts] */
225
+ async getMany({ includeDeleted = false, lang } = {}) {
226
+ await this.#dataStore.indexer.idle()
227
+ const rows = includeDeleted
228
+ ? this.#sql.getManyWithDeleted.all()
229
+ : this.#sql.getMany.all()
230
+ return await Promise.all(
231
+ rows.map(
232
+ async (doc) =>
233
+ await this.#translate(deNullify(/** @type {MapeoDoc} */ (doc)), {
234
+ lang,
235
+ })
236
+ )
237
+ )
238
+ }
239
+
240
+ /**
241
+ *
242
+ * @template {import('type-fest').Exact<TValue, T>} T
243
+ * @param {string | string[]} versionId
244
+ * @param {T} value
245
+ */
246
+ async update(versionId, value) {
247
+ await this.#dataStore.indexer.idle()
248
+ const links = Array.isArray(versionId) ? versionId : [versionId]
249
+ const { docId, createdAt, originalVersionId } = await this.#validateLinks(
250
+ links
251
+ )
252
+ /** @type {any} */
253
+ const doc = {
254
+ // @ts-expect-error Can't figure out why TypeScript doesn't think `value` is spreadable.
255
+ ...value,
256
+ docId,
257
+ createdAt,
258
+ updatedAt: new Date().toISOString(),
259
+ originalVersionId,
260
+ links,
261
+ }
262
+ await this.#dataStore.write(doc)
263
+ return this.getByDocId(docId)
264
+ }
265
+
266
+ /**
267
+ * @param {string} docId
268
+ */
269
+ async delete(docId) {
270
+ await this.#dataStore.indexer.idle()
271
+ const existingDoc = await this.getByDocId(docId)
272
+
273
+ if ('deleted' in existingDoc && existingDoc.deleted) {
274
+ throw new Error('Doc already deleted')
275
+ }
276
+
277
+ /** @type {any} */
278
+ const doc = {
279
+ ...existingDoc,
280
+ updatedAt: new Date().toISOString(),
281
+ // @ts-expect-error - TS just doesn't work in this class
282
+ links: [existingDoc.versionId, ...existingDoc.forks],
283
+ deleted: true,
284
+ }
285
+ await this.#dataStore.write(doc)
286
+ return this.getByDocId(docId)
287
+ }
288
+
289
+ async [kSelect]() {
290
+ await this.#dataStore.indexer.idle()
291
+ const result = this.#db.select().from(this.#table)
292
+ // [The result of `from()` is awaitable.][0] We don't want this because we
293
+ // want to be able to await the result of this method and then call
294
+ // `.where()` on the result.
295
+ //
296
+ // As a workaround, we remove promise methods from the result.
297
+ //
298
+ // [0]: https://github.com/drizzle-team/drizzle-orm/commit/c063144dc08726cc15323582fe377210329e579e
299
+ return removePromiseMethods(result)
300
+ }
301
+
302
+ /**
303
+ * Validate that existing docs with the given versionIds (links):
304
+ * - exist
305
+ * - have the same schemaName as this dataType
306
+ * - all share the same docId
307
+ * - all share the same originalVersionId
308
+ * Throws if any of these conditions fail, otherwise returns the validated
309
+ * docId and createAt datetime
310
+ * @param {string[]} links
311
+ * @returns {Promise<{ docId: MapeoDoc['docId'], createdAt: MapeoDoc['createdAt'], originalVersionId: MapeoDoc['originalVersionId'] }>}
312
+ */
313
+ async #validateLinks(links) {
314
+ const prevDocs = await Promise.all(
315
+ links.map((versionId) => this.getByVersionId(versionId))
316
+ )
317
+ const { docId, createdAt, originalVersionId } = prevDocs[0]
318
+ const areLinksValid = prevDocs.every(
319
+ (doc) =>
320
+ doc.docId === docId &&
321
+ doc.schemaName === this.#schemaName &&
322
+ doc.originalVersionId === originalVersionId
323
+ )
324
+ if (!areLinksValid) {
325
+ throw new Error('Updated docs must have the same docId and schemaName')
326
+ }
327
+ return { docId, createdAt, originalVersionId }
328
+ }
329
+
330
+ /**
331
+ * @param {Set<string>} docIds
332
+ */
333
+ #handleDataStoreUpdate = (docIds) => {
334
+ if (this.listenerCount('updated-docs') === 0) return
335
+ const updatedDocs = /** @type {TDoc[]} */ (
336
+ this.#db
337
+ .select()
338
+ .from(this.#table)
339
+ .where(inArray(this.#table.docId, [...docIds]))
340
+ .all()
341
+ .map((doc) => deNullify(doc))
342
+ )
343
+ this.emit('updated-docs', updatedDocs)
344
+ }
345
+ }
346
+
347
+ /**
348
+ * @template {object} T
349
+ * @param {T} value
350
+ * @returns {Omit<T, 'then' | 'catch' | 'finally'> & { then?: undefined, catch?: undefined, finally?: undefined }}
351
+ */
352
+ function removePromiseMethods(value) {
353
+ return Object.create(value, {
354
+ then: { value: undefined },
355
+ catch: { value: undefined },
356
+ finally: { value: undefined },
357
+ })
358
+ }
@@ -0,0 +1,303 @@
1
+ import { TypedEmitter } from 'tiny-typed-emitter'
2
+ import net from 'node:net'
3
+ import { randomBytes } from 'node:crypto'
4
+ import NoiseSecretStream from '@hyperswarm/secret-stream'
5
+ import { once } from 'node:events'
6
+ import { noop } from '../utils.js'
7
+ import { isPrivate } from 'bogon'
8
+ import StartStopStateMachine from 'start-stop-state-machine'
9
+ import pTimeout from 'p-timeout'
10
+ import { keyToPublicId } from '@mapeo/crypto'
11
+ import { Logger } from '../logger.js'
12
+ /** @import { OpenedNoiseStream } from '../lib/noise-secret-stream-helpers.js' */
13
+
14
+ /** @typedef {{ publicKey: Buffer, secretKey: Buffer }} Keypair */
15
+ /** @typedef {OpenedNoiseStream<net.Socket>} OpenedNetNoiseStream */
16
+
17
+ const TCP_KEEP_ALIVE_OPTIONS = {
18
+ keepAlive: true,
19
+ keepAliveInitialDelay: 10_000,
20
+ }
21
+ export const ERR_DUPLICATE = 'Duplicate connection'
22
+
23
+ /**
24
+ * @typedef {Object} DiscoveryEvents
25
+ * @property {(connection: OpenedNetNoiseStream) => void} connection
26
+ */
27
+
28
+ /**
29
+ * @extends {TypedEmitter<DiscoveryEvents>}
30
+ */
31
+ export class LocalDiscovery extends TypedEmitter {
32
+ #identityKeypair
33
+ #name = randomBytes(8).toString('hex')
34
+ #server
35
+ /** @type {Map<string, OpenedNetNoiseStream>} */
36
+ #noiseConnections = new Map()
37
+ #sm
38
+ #log
39
+ /** @type {(e: Error) => void} */
40
+ #handleSocketError
41
+ #l
42
+
43
+ /**
44
+ * @param {Object} opts
45
+ * @param {Keypair} opts.identityKeypair
46
+ * @param {Logger} [opts.logger]
47
+ */
48
+ constructor({ identityKeypair, logger }) {
49
+ super()
50
+ this.#l = Logger.create('LocalDiscovery', logger)
51
+ this.#log = this.#l.log.bind(this.#l)
52
+ this.#sm = new StartStopStateMachine({
53
+ start: this.#start.bind(this),
54
+ stop: this.#stop.bind(this),
55
+ })
56
+ this.#handleSocketError = (e) => {
57
+ this.#log('socket error', e.message)
58
+ }
59
+ this.#identityKeypair = identityKeypair
60
+ this.#server = net.createServer(
61
+ TCP_KEEP_ALIVE_OPTIONS,
62
+ this.#handleTcpConnection.bind(this, false)
63
+ )
64
+ this.#server.on('error', (e) => {
65
+ this.#log('Server error', e)
66
+ })
67
+ }
68
+
69
+ /** @returns {Promise<{ name: string, port: number }>} */
70
+ async start() {
71
+ await this.#sm.start()
72
+ return { name: this.#name, port: getAddress(this.#server).port }
73
+ }
74
+
75
+ /** @returns {Promise<void>} */
76
+ async #start() {
77
+ // Let OS choose port, listen on ip4, all interfaces
78
+ this.#server.listen(0, '0.0.0.0')
79
+ await once(this.#server, 'listening')
80
+ const addr = getAddress(this.#server)
81
+ this.#log('server listening on port ' + addr.port)
82
+ }
83
+
84
+ /**
85
+ * @param {object} peer
86
+ * @param {string} peer.address
87
+ * @param {number} peer.port
88
+ * @param {string} peer.name
89
+ * @returns {void}
90
+ */
91
+ connectPeer({ address, port, name }) {
92
+ if (this.#name === name) return
93
+ this.#log('peer connected', name.slice(0, 7), address, port)
94
+ if (this.#noiseConnections.has(name)) {
95
+ this.#log(`Already connected to ${name.slice(0, 7)}`)
96
+ return
97
+ }
98
+ const socket = net.connect({
99
+ host: address,
100
+ port,
101
+ ...TCP_KEEP_ALIVE_OPTIONS,
102
+ })
103
+ socket.on('error', this.#handleSocketError)
104
+ socket.once('connect', () => {
105
+ this.#handleTcpConnection(true, socket)
106
+ })
107
+ }
108
+
109
+ /**
110
+ * @param {boolean} isInitiator
111
+ * @param {net.Socket} socket
112
+ * @returns {void}
113
+ */
114
+ #handleTcpConnection(isInitiator, socket) {
115
+ socket.off('error', this.#handleSocketError)
116
+ socket.on('error', onSocketError)
117
+
118
+ /** @param {Error} e */
119
+ function onSocketError(e) {
120
+ if ('code' in e && e.code === 'EPIPE') {
121
+ socket.destroy()
122
+ if (secretStream) {
123
+ secretStream.destroy()
124
+ }
125
+ }
126
+ }
127
+
128
+ const { remoteAddress } = socket
129
+ if (!remoteAddress || !isPrivate(remoteAddress)) {
130
+ socket.destroy(new Error('Invalid remoteAddress ' + remoteAddress))
131
+ return
132
+ }
133
+ this.#log(
134
+ `${isInitiator ? 'outgoing' : 'incoming'} tcp connection ${
135
+ isInitiator ? 'to' : 'from'
136
+ } ${remoteAddress}`
137
+ )
138
+
139
+ const secretStream = new NoiseSecretStream(isInitiator, socket, {
140
+ keyPair: this.#identityKeypair,
141
+ })
142
+
143
+ secretStream.on('error', this.#handleSocketError)
144
+
145
+ secretStream.on('connect', () => {
146
+ // Further errors will be handled in #handleNoiseStreamConnection()
147
+ socket.off('error', onSocketError)
148
+ secretStream.off('error', this.#handleSocketError)
149
+ this.#handleNoiseStreamConnection(
150
+ // We know the NoiseStream is open at this point, so we can coerce the type
151
+ /** @type {OpenedNetNoiseStream} */
152
+ (secretStream)
153
+ )
154
+ })
155
+ }
156
+
157
+ /**
158
+ * @param {OpenedNetNoiseStream} existing
159
+ * @param {OpenedNetNoiseStream} keeping
160
+ * @returns {void}
161
+ */
162
+ #handleConnectionSwap(existing, keeping) {
163
+ let closed = false
164
+
165
+ existing.on('close', () => {
166
+ // The connection we are keeping could have closed before we get here
167
+ if (closed) return
168
+
169
+ keeping.removeListener('error', noop)
170
+ keeping.removeListener('close', onclose)
171
+
172
+ this.#handleNoiseStreamConnection(keeping)
173
+ })
174
+
175
+ keeping.on('error', noop)
176
+ keeping.on('close', onclose)
177
+
178
+ function onclose() {
179
+ closed = true
180
+ }
181
+ }
182
+
183
+ /**
184
+ * @param {OpenedNetNoiseStream} conn
185
+ * @returns {void}
186
+ */
187
+ #handleNoiseStreamConnection(conn) {
188
+ const { remotePublicKey, isInitiator } = conn
189
+ if (!remotePublicKey) {
190
+ // Shouldn't get here
191
+ this.#log('Error: incoming connection with no publicKey')
192
+ conn.destroy()
193
+ return
194
+ }
195
+ const remoteId = keyToPublicId(remotePublicKey)
196
+
197
+ this.#log(
198
+ `${isInitiator ? 'outgoing' : 'incoming'} secret stream connection ${
199
+ isInitiator ? 'to' : 'from'
200
+ } %h`,
201
+ remotePublicKey
202
+ )
203
+
204
+ const existing = this.#noiseConnections.get(remoteId)
205
+
206
+ if (existing) {
207
+ const keyCompare = Buffer.compare(
208
+ this.#identityKeypair.publicKey,
209
+ remotePublicKey
210
+ )
211
+ const keepExisting =
212
+ // These first two checks check if a peer tried to connect twice. In
213
+ // this case we keep the existing connection.
214
+ (isInitiator && existing.isInitiator) ||
215
+ (!isInitiator && !existing.isInitiator) ||
216
+ // If each peer tried to connect to the other at the same time, then we
217
+ // tie-break based on public key comparison (the initiator need to check
218
+ // the opposite of the non-initiator, because the keys are the other way
219
+ // around for them)
220
+ (isInitiator ? keyCompare > 0 : keyCompare <= 0)
221
+ if (keepExisting) {
222
+ this.#log(`keeping existing, destroying new`)
223
+ conn.on('error', noop)
224
+ conn.destroy(new Error(ERR_DUPLICATE))
225
+ return
226
+ } else {
227
+ this.#log(`destroying existing, keeping new`)
228
+ existing.on('error', noop)
229
+ existing.destroy(new Error(ERR_DUPLICATE))
230
+ this.#handleConnectionSwap(existing, conn)
231
+ return
232
+ }
233
+ }
234
+
235
+ // Bail if the server has already stopped by this point. This should be
236
+ // rare but can happen during connection swaps if the new connection is
237
+ // "promoted" after the server's doors have already been closed.
238
+ if (!this.#server.listening) {
239
+ this.#log('server stopped, destroying connection %h', remotePublicKey)
240
+ conn.destroy()
241
+ return
242
+ }
243
+
244
+ this.#noiseConnections.set(remoteId, conn)
245
+
246
+ conn.on('close', () => {
247
+ this.#log('closed connection with %h', remotePublicKey)
248
+ this.#noiseConnections.delete(remoteId)
249
+ })
250
+
251
+ // No 'error' listeners attached to `conn` at this point, it's up to the
252
+ // consumer to attach an 'error' listener to avoid uncaught errors.
253
+ this.emit('connection', conn)
254
+ }
255
+
256
+ /**
257
+ * Close all servers and stop multicast advertising and browsing. Will wait
258
+ * for open sockets to close unless opts.force=true in which case open sockets
259
+ * are force-closed after opts.timeout milliseconds
260
+ *
261
+ * @param {object} [opts]
262
+ * @param {boolean} [opts.force=false] Force-close open sockets after timeout milliseconds
263
+ * @param {number} [opts.timeout=0] Optional timeout when calling stop() with force=true
264
+ * @returns {Promise<void>}
265
+ */
266
+ async stop(opts) {
267
+ return this.#sm.stop(opts)
268
+ }
269
+
270
+ /**
271
+ * @type {LocalDiscovery['stop']}
272
+ */
273
+ async #stop({ force = false, timeout = 0 } = {}) {
274
+ this.#log('stopping')
275
+ const { port } = getAddress(this.#server)
276
+ this.#server.close()
277
+ const closePromise = once(this.#server, 'close')
278
+ await pTimeout(closePromise, {
279
+ milliseconds: force ? (timeout === 0 ? 1 : timeout) : Infinity,
280
+ fallback: () => {
281
+ for (const socket of this.#noiseConnections.values()) {
282
+ socket.destroy()
283
+ }
284
+ return pTimeout(closePromise, { milliseconds: 500 })
285
+ },
286
+ })
287
+ this.#log(`stopped for ${port}`)
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Get the address of a server, will throw if the server is not yet listening or
293
+ * if it is listening on a socket
294
+ * @param {import('node:net').Server} server
295
+ * @returns {import('node:net').AddressInfo}
296
+ */
297
+ function getAddress(server) {
298
+ const addr = server.address()
299
+ if (addr === null || typeof addr === 'string') {
300
+ throw new Error('Server is not listening on a port')
301
+ }
302
+ return addr
303
+ }
package/src/errors.js ADDED
@@ -0,0 +1,5 @@
1
+ export class NotFoundError extends Error {
2
+ constructor() {
3
+ super('Not found')
4
+ }
5
+ }