@comapeo/core 2.3.1 → 2.3.2

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.
@@ -2,6 +2,7 @@ import SubEncoder from 'sub-encoder'
2
2
  import mergeStreams from '@sindresorhus/merge-streams'
3
3
  import { Transform, pipeline } from 'node:stream'
4
4
  import { noop } from '../utils.js'
5
+ import ensureError from 'ensure-error'
5
6
 
6
7
  /** @import Hyperdrive from 'hyperdrive' */
7
8
  /** @import { BlobStoreEntriesStream } from '../types.js' */
@@ -18,7 +19,7 @@ const keyEncoding = new SubEncoder('files', 'utf-8')
18
19
  */
19
20
  export function createEntriesStream(driveIndex, { live = false } = {}) {
20
21
  const mergedEntriesStreams = mergeStreams(
21
- [...driveIndex].map((drive) => getHistoryStream(drive.db, { live }))
22
+ [...driveIndex].map((drive) => getHistoryStream(drive, { live }))
22
23
  )
23
24
  driveIndex.on('add-drive', addDrive)
24
25
  // Close is always emitted, so we can use it to remove the listener
@@ -29,53 +30,77 @@ export function createEntriesStream(driveIndex, { live = false } = {}) {
29
30
 
30
31
  /** @param {Hyperdrive} drive */
31
32
  function addDrive(drive) {
32
- mergedEntriesStreams.add(getHistoryStream(drive.db, { live }))
33
+ mergedEntriesStreams.add(getHistoryStream(drive, { live }))
33
34
  }
34
35
  }
35
36
 
36
37
  /**
37
38
  *
38
- * @param {import('hyperbee')} bee
39
+ * @param {Hyperdrive} drive
39
40
  * @param {object} opts
40
41
  * @param {boolean} opts.live
41
42
  */
42
- function getHistoryStream(bee, { live }) {
43
+ function getHistoryStream(drive, { live }) {
43
44
  // This will also include old versions of files, but it is the only way to
44
45
  // get a live stream from a Hyperbee, however we currently do not support
45
46
  // edits of blobs, so this should not be an issue, and the consequence is
46
47
  // that old versions are downloaded too, which is acceptable.
47
- const historyStream = bee.createHistoryStream({
48
+ const historyStream = drive.db.createHistoryStream({
48
49
  live,
49
50
  // `keyEncoding` is necessary because hyperdrive stores file index data
50
51
  // under the `files` sub-encoding key
51
52
  keyEncoding,
52
53
  })
53
- return pipeline(historyStream, new AddDriveIds(bee.core), noop)
54
+ return pipeline(historyStream, new AddDriveIds(drive), noop)
54
55
  }
55
56
 
56
57
  class AddDriveIds extends Transform {
57
- #core
58
+ #drive
59
+ /** @type {string | undefined} */
58
60
  #cachedDriveId
61
+ /** @type {string | undefined} */
62
+ #cachedBlobCoreId
59
63
 
60
- /** @param {import('hypercore')} core */
61
- constructor(core) {
64
+ /** @param {Hyperdrive} drive */
65
+ constructor(drive) {
62
66
  super({ objectMode: true })
63
- this.#core = core
64
- this.#cachedDriveId = core.discoveryKey?.toString('hex')
67
+ this.#drive = drive
68
+ this.#cachedDriveId = drive.db.core.discoveryKey?.toString('hex')
65
69
  }
66
70
 
67
- /** @type {Transform['_transform']} */
68
- _transform(entry, _, callback) {
71
+ get #driveId() {
69
72
  // Minimal performance optimization to only call toString() once.
70
73
  // core.discoveryKey will always be defined by the time it starts
71
74
  // streaming, but could be null when the instance is first created.
72
- let driveId
73
75
  if (this.#cachedDriveId) {
74
- driveId = this.#cachedDriveId
76
+ return this.#cachedDriveId
75
77
  } else {
76
- driveId = this.#core.discoveryKey?.toString('hex')
77
- this.#cachedDriveId = driveId
78
+ this.#cachedDriveId = this.#drive.db.core.discoveryKey?.toString('hex')
79
+ return this.#cachedDriveId
80
+ }
81
+ }
82
+
83
+ async #getBlobCoreId() {
84
+ if (this.#cachedBlobCoreId) return this.#cachedBlobCoreId
85
+ const blobs = await this.#drive.getBlobs()
86
+ this.#cachedBlobCoreId = blobs.core.discoveryKey?.toString('hex')
87
+ return this.#cachedBlobCoreId
88
+ }
89
+
90
+ /** @type {Transform['_transform']} */
91
+ _transform(entry, _, callback) {
92
+ if (!this.#driveId) {
93
+ return callback(new Error('Drive discovery key unexpectedly missing'))
78
94
  }
79
- callback(null, { ...entry, driveId })
95
+ this.#getBlobCoreId()
96
+ .then((blobCoreId) => {
97
+ if (!blobCoreId) {
98
+ return callback(
99
+ new Error('Blob core discovery key unexpectedly missing')
100
+ )
101
+ }
102
+ callback(null, { ...entry, driveId: this.#driveId, blobCoreId })
103
+ })
104
+ .catch((reason) => callback(ensureError(reason)))
80
105
  }
81
106
  }
@@ -6,12 +6,26 @@ import { FilterEntriesStream } from './utils.js'
6
6
  import { noop } from '../utils.js'
7
7
  import { TypedEmitter } from 'tiny-typed-emitter'
8
8
  import { HyperdriveIndexImpl as HyperdriveIndex } from './hyperdrive-index.js'
9
+ import { Logger } from '../logger.js'
10
+ import { getErrorCode, getErrorMessage } from '../lib/error.js'
9
11
 
10
12
  /** @import Hyperdrive from 'hyperdrive' */
11
13
  /** @import { JsonObject } from 'type-fest' */
12
14
  /** @import { Readable as NodeReadable } from 'node:stream' */
13
15
  /** @import { Readable as StreamxReadable, Writable } from 'streamx' */
14
- /** @import { BlobFilter, BlobId, BlobStoreEntriesStream } from '../types.js' */
16
+ /** @import { GenericBlobFilter, BlobFilter, BlobId, BlobStoreEntriesStream } from '../types.js' */
17
+
18
+ /**
19
+ * @typedef {object} BlobStoreEvents
20
+ * @prop {(peerId: string, blobFilter: GenericBlobFilter | null) => void} blob-filter
21
+ * @prop {(opts: {
22
+ * peerId: string
23
+ * start: number
24
+ * length: number
25
+ * blobCoreId: string
26
+ * }) => void} want-blob-range
27
+ * @prop {(error: Error) => void} error
28
+ */
15
29
 
16
30
  /**
17
31
  * @internal
@@ -31,6 +45,13 @@ const SUPPORTED_BLOB_VARIANTS = /** @type {const} */ ({
31
45
  // the type with JSDoc
32
46
  export { SUPPORTED_BLOB_VARIANTS }
33
47
 
48
+ /** @type {import('../types.js').BlobFilter} */
49
+ const NON_ARCHIVE_DEVICE_DOWNLOAD_FILTER = {
50
+ photo: ['preview', 'thumbnail'],
51
+ // Don't download any audio of video files, since previews and
52
+ // thumbnails aren't supported yet.
53
+ }
54
+
34
55
  class ErrNotFound extends Error {
35
56
  constructor(message = 'NotFound') {
36
57
  super(message)
@@ -38,24 +59,117 @@ class ErrNotFound extends Error {
38
59
  }
39
60
  }
40
61
 
41
- /** @extends {TypedEmitter<{ error: (error: Error) => void }>} */
62
+ /** @extends {TypedEmitter<BlobStoreEvents>} */
42
63
  export class BlobStore extends TypedEmitter {
43
64
  #driveIndex
44
65
  /** @type {Downloader} */
45
66
  #downloader
67
+ /** @type {Map<string, GenericBlobFilter | null>} */
68
+ #blobFilters = new Map()
69
+ #l
70
+ /** @type {Map<string, BlobStoreEntriesStream>} */
71
+ #entriesStreams = new Map()
72
+ #isArchiveDevice
73
+ #deviceId
74
+
75
+ /**
76
+ * Bound function for handling download intents for both peers and self
77
+ * @param {GenericBlobFilter | null} filter
78
+ * @param {string} peerId
79
+ */
80
+ #handleDownloadIntent = async (filter, peerId) => {
81
+ this.#l.log('Download intent %o for peer %S', filter, peerId)
82
+ try {
83
+ this.#entriesStreams.get(peerId)?.destroy()
84
+ this.emit('blob-filter', peerId, filter)
85
+ this.#blobFilters.set(peerId, filter)
86
+
87
+ if (filter === null) return
88
+
89
+ const entriesReadStream = this.createEntriesReadStream({
90
+ live: true,
91
+ filter,
92
+ })
93
+ this.#entriesStreams.set(peerId, entriesReadStream)
94
+
95
+ entriesReadStream.once('close', () => {
96
+ if (this.#entriesStreams.get(peerId) === entriesReadStream) {
97
+ this.#entriesStreams.delete(peerId)
98
+ }
99
+ })
100
+
101
+ for await (const {
102
+ blobCoreId,
103
+ value: { blob },
104
+ } of entriesReadStream) {
105
+ const { blockOffset: start, blockLength: length } = blob
106
+ this.emit('want-blob-range', {
107
+ peerId,
108
+ start,
109
+ length,
110
+ blobCoreId,
111
+ })
112
+ }
113
+ } catch (err) {
114
+ if (getErrorCode(err) === 'ERR_STREAM_PREMATURE_CLOSE') return
115
+ this.#l.log(
116
+ 'Error getting blob entries stream for peer %h: %s',
117
+ peerId,
118
+ getErrorMessage(err)
119
+ )
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Bound to `this`
125
+ * This will be called whenever a peer is successfully added to the creatorcore
126
+ * @param {import('../types.js').HypercorePeer & { protomux: import('protomux')<import('../lib/noise-secret-stream-helpers.js').OpenedNoiseStream> }} peer
127
+ */
128
+ #handlePeerAdd = (peer) => {
129
+ const downloadFilter = getBlobDownloadFilter(this.#isArchiveDevice)
130
+ this.#coreManager.sendDownloadIntents(downloadFilter, peer)
131
+ }
132
+
133
+ /**
134
+ * Bound to `this`
135
+ * @param {import('../types.js').HypercorePeer & { protomux: import('protomux')<import('../lib/noise-secret-stream-helpers.js').OpenedNoiseStream> }} peer
136
+ */
137
+ #handlePeerRemove = (peer) => {
138
+ const peerKey = peer.protomux.stream.remotePublicKey
139
+ const peerId = peerKey.toString('hex')
140
+ this.#entriesStreams.get(peerId)?.destroy()
141
+ this.#entriesStreams.delete(peerId)
142
+ }
143
+
144
+ #coreManager
145
+ #logger
46
146
 
47
147
  /**
48
148
  * @param {object} options
49
149
  * @param {import('../core-manager/index.js').CoreManager} options.coreManager
50
- * @param {BlobFilter | null} options.downloadFilter - Filter blob types and/or variants to download. Set to `null` to download all blobs.
150
+ * @param {boolean} [options.isArchiveDevice] Set to `true` if this is an archive device which should download all blobs, or just a selection of blobs
151
+ * @param {import('../logger.js').Logger} [options.logger]
51
152
  */
52
- constructor({ coreManager, downloadFilter }) {
153
+ constructor({ coreManager, isArchiveDevice = true, logger }) {
53
154
  super()
155
+ this.#logger = logger
156
+ this.#l = Logger.create('blobStore', logger)
157
+ this.#isArchiveDevice = isArchiveDevice
54
158
  this.#driveIndex = new HyperdriveIndex(coreManager)
159
+ this.#coreManager = coreManager
160
+ this.#deviceId = coreManager.deviceId
161
+ const downloadFilter = getBlobDownloadFilter(isArchiveDevice)
162
+ if (downloadFilter) {
163
+ this.#handleDownloadIntent(downloadFilter, this.#deviceId)
164
+ }
55
165
  this.#downloader = new Downloader(this.#driveIndex, {
56
166
  filter: downloadFilter,
57
167
  })
58
168
  this.#downloader.on('error', (error) => this.emit('error', error))
169
+
170
+ coreManager.on('peer-download-intent', this.#handleDownloadIntent)
171
+ coreManager.creatorCore.on('peer-add', this.#handlePeerAdd)
172
+ coreManager.creatorCore.on('peer-remove', this.#handlePeerRemove)
59
173
  }
60
174
 
61
175
  /**
@@ -65,6 +179,38 @@ export class BlobStore extends TypedEmitter {
65
179
  return getDiscoveryId(this.#driveIndex.writerKey)
66
180
  }
67
181
 
182
+ get isArchiveDevice() {
183
+ return this.#isArchiveDevice
184
+ }
185
+
186
+ /**
187
+ * @param {string} peerId
188
+ * @returns {GenericBlobFilter | null}
189
+ */
190
+ getBlobFilter(peerId) {
191
+ return this.#blobFilters.get(peerId) ?? null
192
+ }
193
+
194
+ /** @param {boolean} isArchiveDevice */
195
+ async setIsArchiveDevice(isArchiveDevice) {
196
+ this.#l.log('Setting isArchiveDevice to %s', isArchiveDevice)
197
+ if (this.#isArchiveDevice === isArchiveDevice) return
198
+ this.#isArchiveDevice = isArchiveDevice
199
+ const blobDownloadFilter = getBlobDownloadFilter(isArchiveDevice)
200
+ this.#downloader.removeAllListeners()
201
+ this.#downloader.destroy()
202
+ this.#downloader = new Downloader(this.#driveIndex, {
203
+ filter: blobDownloadFilter,
204
+ })
205
+ this.#downloader.on('error', (error) => this.emit('error', error))
206
+ // Even if blobFilter is null, e.g. we plan to download everything, we still
207
+ // need to inform connected peers of the change.
208
+ for (const peer of this.#coreManager.creatorCore.peers) {
209
+ this.#coreManager.sendDownloadIntents(blobDownloadFilter, peer)
210
+ }
211
+ this.#handleDownloadIntent(blobDownloadFilter, this.#deviceId)
212
+ }
213
+
68
214
  /**
69
215
  * @param {string} driveId hex-encoded discovery key
70
216
  * @returns {Hyperdrive}
@@ -90,21 +236,6 @@ export class BlobStore extends TypedEmitter {
90
236
  return blob
91
237
  }
92
238
 
93
- /**
94
- * Set the filter for downloading blobs.
95
- *
96
- * @param {import('../types.js').BlobFilter | null} filter Filter blob types and/or variants to download. Filter is { [BlobType]: BlobVariants[] }. At least one blob variant must be specified for each blob type.
97
- * @returns {void}
98
- */
99
- setDownloadFilter(filter) {
100
- this.#downloader.removeAllListeners()
101
- this.#downloader.destroy()
102
- this.#downloader = new Downloader(this.#driveIndex, {
103
- filter,
104
- })
105
- this.#downloader.on('error', (error) => this.emit('error', error))
106
- }
107
-
108
239
  /**
109
240
  * @param {BlobId} blobId
110
241
  * @param {object} [options]
@@ -245,6 +376,9 @@ export class BlobStore extends TypedEmitter {
245
376
  close() {
246
377
  this.#downloader.removeAllListeners()
247
378
  this.#downloader.destroy()
379
+ this.#coreManager.off('peer-download-intent', this.#handleDownloadIntent)
380
+ this.#coreManager.creatorCore.off('peer-add', this.#handlePeerAdd)
381
+ this.#coreManager.creatorCore.off('peer-remove', this.#handlePeerRemove)
248
382
  }
249
383
  }
250
384
 
@@ -280,3 +414,11 @@ function makePath({ type, variant, name }) {
280
414
  function getDiscoveryId(key) {
281
415
  return discoveryKey(key).toString('hex')
282
416
  }
417
+
418
+ /**
419
+ * @param {boolean} isArchiveDevice
420
+ * @returns {null | BlobFilter}
421
+ */
422
+ function getBlobDownloadFilter(isArchiveDevice) {
423
+ return isArchiveDevice ? null : NON_ARCHIVE_DEVICE_DOWNLOAD_FILTER
424
+ }
@@ -30,7 +30,7 @@ export const kCoreManagerReplicate = Symbol('replicate core manager')
30
30
  * @typedef {Object} Events
31
31
  * @property {(coreRecord: CoreRecord) => void} add-core
32
32
  * @property {(namespace: Namespace, msg: { coreDiscoveryId: string, peerId: string, start: number, bitfield: Uint32Array }) => void} peer-have
33
- * @property {(blobFilter: GenericBlobFilter, peerId: string) => void} peer-download-intent
33
+ * @property {(blobFilter: GenericBlobFilter | null, peerId: string) => void} peer-download-intent
34
34
  */
35
35
 
36
36
  /**
@@ -412,7 +412,7 @@ export class CoreManager extends TypedEmitter {
412
412
  }
413
413
 
414
414
  /**
415
- * @param {GenericBlobFilter} blobFilter
415
+ * @param {GenericBlobFilter | null} blobFilter
416
416
  * @param {HypercorePeer} peer
417
417
  */
418
418
  #handleDownloadIntentMessage(blobFilter, peer) {
@@ -421,7 +421,7 @@ export class CoreManager extends TypedEmitter {
421
421
  }
422
422
 
423
423
  /**
424
- * @param {BlobFilter} blobFilter
424
+ * @param {BlobFilter | null} blobFilter
425
425
  * @param {HypercorePeer} peer
426
426
  */
427
427
  sendDownloadIntents(blobFilter, peer) {
@@ -540,20 +540,27 @@ const HaveExtensionCodec = {
540
540
  }
541
541
 
542
542
  const DownloadIntentCodec = {
543
- /** @param {BlobFilter} filter */
543
+ /** @param {BlobFilter | null} filter */
544
544
  encode(filter) {
545
- const downloadIntents = mapObject(filter, (key, value) => [
545
+ const downloadIntents = mapObject(filter || {}, (key, value) => [
546
546
  key,
547
547
  { variants: value || [] },
548
548
  ])
549
- return DownloadIntentExtension.encode({ downloadIntents }).finish()
549
+ // If filter is null, we want to download everything
550
+ const everything = !filter
551
+ return DownloadIntentExtension.encode({
552
+ downloadIntents,
553
+ everything,
554
+ }).finish()
550
555
  },
551
556
  /**
552
557
  * @param {Buffer | Uint8Array} buf
553
- * @returns {GenericBlobFilter}
558
+ * @returns {GenericBlobFilter | null}
554
559
  */
555
560
  decode(buf) {
556
561
  const msg = DownloadIntentExtension.decode(buf)
562
+ // If everything is true, we ignore the downloadIntents and return null, which means download everything
563
+ if (msg.everything) return null
557
564
  return mapObject(msg.downloadIntents, (key, value) => [
558
565
  key + '', // keep TS happy
559
566
  value.variants,
@@ -24,6 +24,13 @@ export interface DownloadIntentExtension {
24
24
  downloadIntents: {
25
25
  [key: string]: DownloadIntentExtension_DownloadIntent;
26
26
  };
27
+ /**
28
+ * If true, the peer intends to download all blobs - this is the default
29
+ * assumption when a peer has not sent a download intent, but if a peer
30
+ * changes their intent while connected, we need to send the new intent to
31
+ * download everything.
32
+ */
33
+ everything: boolean;
27
34
  }
28
35
  export interface DownloadIntentExtension_DownloadIntent {
29
36
  variants: string[];
@@ -170,7 +170,7 @@ export var HaveExtension = {
170
170
  },
171
171
  };
172
172
  function createBaseDownloadIntentExtension() {
173
- return { downloadIntents: {} };
173
+ return { downloadIntents: {}, everything: false };
174
174
  }
175
175
  export var DownloadIntentExtension = {
176
176
  encode: function (message, writer) {
@@ -180,6 +180,9 @@ export var DownloadIntentExtension = {
180
180
  DownloadIntentExtension_DownloadIntentsEntry.encode({ key: key, value: value }, writer.uint32(10).fork())
181
181
  .ldelim();
182
182
  });
183
+ if (message.everything === true) {
184
+ writer.uint32(16).bool(message.everything);
185
+ }
183
186
  return writer;
184
187
  },
185
188
  decode: function (input, length) {
@@ -198,6 +201,12 @@ export var DownloadIntentExtension = {
198
201
  message.downloadIntents[entry1.key] = entry1.value;
199
202
  }
200
203
  continue;
204
+ case 2:
205
+ if (tag !== 16) {
206
+ break;
207
+ }
208
+ message.everything = reader.bool();
209
+ continue;
201
210
  }
202
211
  if ((tag & 7) === 4 || tag === 0) {
203
212
  break;
@@ -210,7 +219,7 @@ export var DownloadIntentExtension = {
210
219
  return DownloadIntentExtension.fromPartial(base !== null && base !== void 0 ? base : {});
211
220
  },
212
221
  fromPartial: function (object) {
213
- var _a;
222
+ var _a, _b;
214
223
  var message = createBaseDownloadIntentExtension();
215
224
  message.downloadIntents = Object.entries((_a = object.downloadIntents) !== null && _a !== void 0 ? _a : {}).reduce(function (acc, _a) {
216
225
  var key = _a[0], value = _a[1];
@@ -219,6 +228,7 @@ export var DownloadIntentExtension = {
219
228
  }
220
229
  return acc;
221
230
  }, {});
231
+ message.everything = (_b = object.everything) !== null && _b !== void 0 ? _b : false;
222
232
  return message;
223
233
  },
224
234
  };
@@ -69,6 +69,13 @@ export function haveExtension_NamespaceToNumber(object: HaveExtension_Namespace)
69
69
  /** A map of blob types and variants that a peer intends to download */
70
70
  export interface DownloadIntentExtension {
71
71
  downloadIntents: { [key: string]: DownloadIntentExtension_DownloadIntent };
72
+ /**
73
+ * If true, the peer intends to download all blobs - this is the default
74
+ * assumption when a peer has not sent a download intent, but if a peer
75
+ * changes their intent while connected, we need to send the new intent to
76
+ * download everything.
77
+ */
78
+ everything: boolean;
72
79
  }
73
80
 
74
81
  export interface DownloadIntentExtension_DownloadIntent {
@@ -209,7 +216,7 @@ export const HaveExtension = {
209
216
  };
210
217
 
211
218
  function createBaseDownloadIntentExtension(): DownloadIntentExtension {
212
- return { downloadIntents: {} };
219
+ return { downloadIntents: {}, everything: false };
213
220
  }
214
221
 
215
222
  export const DownloadIntentExtension = {
@@ -218,6 +225,9 @@ export const DownloadIntentExtension = {
218
225
  DownloadIntentExtension_DownloadIntentsEntry.encode({ key: key as any, value }, writer.uint32(10).fork())
219
226
  .ldelim();
220
227
  });
228
+ if (message.everything === true) {
229
+ writer.uint32(16).bool(message.everything);
230
+ }
221
231
  return writer;
222
232
  },
223
233
 
@@ -238,6 +248,13 @@ export const DownloadIntentExtension = {
238
248
  message.downloadIntents[entry1.key] = entry1.value;
239
249
  }
240
250
  continue;
251
+ case 2:
252
+ if (tag !== 16) {
253
+ break;
254
+ }
255
+
256
+ message.everything = reader.bool();
257
+ continue;
241
258
  }
242
259
  if ((tag & 7) === 4 || tag === 0) {
243
260
  break;
@@ -260,6 +277,7 @@ export const DownloadIntentExtension = {
260
277
  }
261
278
  return acc;
262
279
  }, {});
280
+ message.everything = object.everything ?? false;
263
281
  return message;
264
282
  },
265
283
  };