@helia/ipns 10.1.1 → 10.2.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 (63) hide show
  1. package/README.md +40 -0
  2. package/dist/index.min.js +5 -5
  3. package/dist/index.min.js.map +4 -4
  4. package/dist/src/errors.d.ts +11 -0
  5. package/dist/src/errors.d.ts.map +1 -1
  6. package/dist/src/errors.js +13 -0
  7. package/dist/src/errors.js.map +1 -1
  8. package/dist/src/index.d.ts +136 -7
  9. package/dist/src/index.d.ts.map +1 -1
  10. package/dist/src/index.js +41 -0
  11. package/dist/src/index.js.map +1 -1
  12. package/dist/src/ipns/publisher.d.ts +3 -3
  13. package/dist/src/ipns/publisher.d.ts.map +1 -1
  14. package/dist/src/ipns/publisher.js +17 -18
  15. package/dist/src/ipns/publisher.js.map +1 -1
  16. package/dist/src/ipns/republisher.d.ts +6 -1
  17. package/dist/src/ipns/republisher.d.ts.map +1 -1
  18. package/dist/src/ipns/republisher.js +157 -24
  19. package/dist/src/ipns/republisher.js.map +1 -1
  20. package/dist/src/ipns/resolver.d.ts +13 -1
  21. package/dist/src/ipns/resolver.d.ts.map +1 -1
  22. package/dist/src/ipns/resolver.js +40 -31
  23. package/dist/src/ipns/resolver.js.map +1 -1
  24. package/dist/src/ipns.d.ts +6 -3
  25. package/dist/src/ipns.d.ts.map +1 -1
  26. package/dist/src/ipns.js +12 -3
  27. package/dist/src/ipns.js.map +1 -1
  28. package/dist/src/local-store.d.ts +5 -0
  29. package/dist/src/local-store.d.ts.map +1 -1
  30. package/dist/src/local-store.js +68 -23
  31. package/dist/src/local-store.js.map +1 -1
  32. package/dist/src/pb/ipns.js +1 -1
  33. package/dist/src/pb/metadata.d.ts +14 -1
  34. package/dist/src/pb/metadata.d.ts.map +1 -1
  35. package/dist/src/pb/metadata.js +36 -3
  36. package/dist/src/pb/metadata.js.map +1 -1
  37. package/dist/src/routing/index.d.ts +13 -1
  38. package/dist/src/routing/index.d.ts.map +1 -1
  39. package/dist/src/routing/index.js +17 -0
  40. package/dist/src/routing/index.js.map +1 -1
  41. package/dist/src/utils.d.ts +5 -0
  42. package/dist/src/utils.d.ts.map +1 -1
  43. package/dist/src/utils.js +18 -0
  44. package/dist/src/utils.js.map +1 -1
  45. package/dist/src/validator.d.ts +6 -0
  46. package/dist/src/validator.d.ts.map +1 -1
  47. package/dist/src/validator.js +24 -14
  48. package/dist/src/validator.js.map +1 -1
  49. package/dist/typedoc-urls.json +23 -1
  50. package/package.json +3 -3
  51. package/src/errors.ts +18 -0
  52. package/src/index.ts +145 -7
  53. package/src/ipns/publisher.ts +22 -20
  54. package/src/ipns/republisher.ts +182 -26
  55. package/src/ipns/resolver.ts +54 -36
  56. package/src/ipns.ts +20 -6
  57. package/src/local-store.ts +77 -22
  58. package/src/pb/ipns.ts +1 -1
  59. package/src/pb/metadata.proto +10 -0
  60. package/src/pb/metadata.ts +45 -4
  61. package/src/routing/index.ts +30 -1
  62. package/src/utils.ts +19 -0
  63. package/src/validator.ts +30 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helia/ipns",
3
- "version": "10.1.1",
3
+ "version": "10.2.0",
4
4
  "description": "An implementation of IPNS for Helia",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "homepage": "https://github.com/ipfs/helia/tree/main/packages/ipns#readme",
@@ -66,7 +66,7 @@
66
66
  "interface-datastore": "^10.0.1",
67
67
  "multiformats": "^14.0.0",
68
68
  "progress-events": "^1.1.0",
69
- "protons-runtime": "^7.1.0",
69
+ "protons-runtime": "^7.1.1",
70
70
  "race-signal": "^2.0.0",
71
71
  "timestamp-nano": "^1.0.1",
72
72
  "uint8arraylist": "^3.0.2",
@@ -78,7 +78,7 @@
78
78
  "datastore-core": "^12.0.1",
79
79
  "it-drain": "^3.0.12",
80
80
  "it-last": "^3.0.11",
81
- "protons": "^9.1.0",
81
+ "protons": "^9.1.1",
82
82
  "sinon": "^22.0.0",
83
83
  "sinon-ts": "^2.0.0"
84
84
  },
package/src/errors.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { IPNSEntry } from './pb/ipns.ts'
2
+
1
3
  export class RecordsFailedValidationError extends Error {
2
4
  static name = 'RecordsFailedValidationError'
3
5
  name = 'RecordsFailedValidationError'
@@ -18,6 +20,22 @@ export class RecordNotFoundError extends Error {
18
20
  name = 'RecordNotFoundError'
19
21
  }
20
22
 
23
+ export class RecordObsoleteError extends Error {
24
+ static name = 'RecordObsoleteError'
25
+ name = 'RecordObsoleteError'
26
+ /**
27
+ * The more suitable record that made the one being imported or republished
28
+ * obsolete.
29
+ */
30
+ readonly record: IPNSEntry
31
+
32
+ constructor (message: string, record: IPNSEntry) {
33
+ super(message)
34
+
35
+ this.record = record
36
+ }
37
+ }
38
+
21
39
  export class SignatureCreationError extends Error {
22
40
  static name = 'SignatureCreationError'
23
41
  name = 'SignatureCreationError'
package/src/index.ts CHANGED
@@ -135,6 +135,46 @@
135
135
  * console.info(result.record.value)
136
136
  * }
137
137
  * ```
138
+ *
139
+ * @example Republishing an existing IPNS record
140
+ *
141
+ * It is sometimes useful to be able to republish an existing IPNS record
142
+ * without needing the private key. This allows you to extend the availability
143
+ * of a record that was created elsewhere.
144
+ *
145
+ * There should be only one republisher per IPNS key. Multiple machines
146
+ * republishing the same key flood the routers with redundant writes.
147
+ *
148
+ * ```TypeScript
149
+ * import { createHelia } from 'helia'
150
+ * import { ipns } from '@helia/ipns'
151
+ * import { delegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client'
152
+ * import { defaultLogger } from 'birnam'
153
+ * import { CID } from 'multiformats/cid'
154
+ *
155
+ * const helia = await createHelia()
156
+ * const name = ipns(helia)
157
+ *
158
+ * const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4'
159
+ * const parsedCid: CID<unknown, 114, 0 | 18, 1> = CID.parse(ipnsName)
160
+ * const delegatedClient = delegatedRoutingV1HttpApiClient({
161
+ * url: 'https://delegated-ipfs.dev'
162
+ * })({
163
+ * logger: defaultLogger()
164
+ * })
165
+ * const record = await delegatedClient.getIPNS(parsedCid)
166
+ *
167
+ * // import the record into the local store (validates and stores it locally,
168
+ * // but does not publish it to the routers)
169
+ * await name.import(parsedCid, record)
170
+ *
171
+ * // start republishing it; throws RecordObsoleteError if the routers
172
+ * // already have a newer record for this key
173
+ * const { record: latestRecord } = await name.republish(parsedCid)
174
+ *
175
+ * // stop republishing a key
176
+ * await name.unpublish(parsedCid)
177
+ * ```
138
178
  */
139
179
 
140
180
  import { CID } from 'multiformats/cid'
@@ -155,6 +195,7 @@ import type { ProgressEvent, ProgressOptions } from 'progress-events'
155
195
 
156
196
  export * from './routing/index.ts'
157
197
  export * from './pb/ipns.ts'
198
+ export * from './errors.ts'
158
199
 
159
200
  export {
160
201
  multihashFromIPNSRoutingKey,
@@ -184,6 +225,14 @@ export type DatastoreProgressEvents =
184
225
  ProgressEvent<'ipns:routing:datastore:list'> |
185
226
  ProgressEvent<'ipns:routing:datastore:error', Error>
186
227
 
228
+ /**
229
+ * The automated upkeep policy for a stored IPNS record. `republish()` accepts
230
+ * every policy except `reissue`, which needs the private key to re-sign.
231
+ *
232
+ * These strings must match the `Upkeep` enum values in `pb/metadata.proto`.
233
+ */
234
+ export type UpkeepPolicy = 'reissue' | 'rebroadcast' | 'none'
235
+
187
236
  export interface PublishOptions extends AbortOptions, ProgressOptions<PublishProgressEvents | IPNSRoutingProgressEvents> {
188
237
  /**
189
238
  * Time duration of the signature validity in ms - after this many ms have
@@ -219,6 +268,17 @@ export interface PublishOptions extends AbortOptions, ProgressOptions<PublishPro
219
268
  */
220
269
  ttl?: number
221
270
 
271
+ /**
272
+ * Automated record upkeep policy.
273
+ *
274
+ * - `reissue`: re-sign the record with a new validity
275
+ * - `rebroadcast`: re-publish the existing record until it expires
276
+ * - `none`: disable automated publishing
277
+ *
278
+ * @default 'reissue'
279
+ */
280
+ upkeep?: UpkeepPolicy
281
+
222
282
  /**
223
283
  * Extensible data that will be added to the IPNS record data and signed to
224
284
  * verify it's integrity.
@@ -273,6 +333,43 @@ export interface IPNSResolveOptions extends AbortOptions, ProgressOptions<Resolv
273
333
  validate?: boolean
274
334
  }
275
335
 
336
+ export interface RepublishOptions extends AbortOptions, ProgressOptions<IPNSRoutingProgressEvents> {
337
+ /**
338
+ * Skip resolution of latest record before republishing.
339
+ *
340
+ * It's important to resolve the latest record before republishing to routers
341
+ *
342
+ * Resolution should only be skipped when confident the latest record is
343
+ * already known.
344
+ *
345
+ * @default false
346
+ */
347
+ skipResolution?: boolean
348
+
349
+ /**
350
+ * Automated record upkeep policy.
351
+ *
352
+ * Defaults to `rebroadcast` since `republish()` cannot sign new records
353
+ * without the private key.
354
+ *
355
+ * - `rebroadcast`: re-publish the existing record until it expires
356
+ * - `none`: disable automated publishing
357
+ *
358
+ * @default 'rebroadcast'
359
+ */
360
+ upkeep?: Exclude<UpkeepPolicy, 'reissue'>
361
+ }
362
+
363
+ export interface UnpublishOptions extends AbortOptions {
364
+ /**
365
+ * Also delete the record from the local store, so the node stops serving it
366
+ * immediately instead of keeping it until it expires.
367
+ *
368
+ * @default false
369
+ */
370
+ removeRecord?: boolean
371
+ }
372
+
276
373
  export interface IPNSResolveResult {
277
374
  /**
278
375
  * The resolved record
@@ -302,6 +399,18 @@ export interface IPNSPublishResult {
302
399
  publicKey: PublicKey
303
400
  }
304
401
 
402
+ export interface RepublishResult {
403
+ /**
404
+ * The published record
405
+ */
406
+ record: IPNSEntry
407
+
408
+ /**
409
+ * The public key that the record is published under
410
+ */
411
+ publicKey: PublicKey
412
+ }
413
+
305
414
  export interface IPNSResolver {
306
415
  /**
307
416
  * Accepts a CID with the libp2p-key codec and either the identity hash (for
@@ -345,7 +454,7 @@ export interface IPNS {
345
454
  * signal: AbortSignal.timeout(5_000)
346
455
  * })
347
456
  *
348
- * console.info(result) // { answer: ... }
457
+ * console.info(result) // { record, name, publicKey }
349
458
  * ```
350
459
  */
351
460
  publish(keyName: string, value: CID | PublicKey | MultihashDigest | string, options?: PublishOptions): Promise<IPNSPublishResult>
@@ -357,17 +466,46 @@ export interface IPNS {
357
466
  * (e.g. the value can be parsed as a string that does not start with
358
467
  * `/ipns/`).
359
468
  */
360
- resolve(name: CID | PublicKey | MultihashDigest | string, options?: IPNSResolveOptions): AsyncGenerator<IPNSResolveResult>
469
+ resolve(name: CID<unknown, 0x72> | PublicKey | MultihashDigest | string, options?: IPNSResolveOptions): AsyncGenerator<IPNSResolveResult>
470
+
471
+ /**
472
+ * Stop automatically republishing an IPNS record
473
+ *
474
+ * By default this removes only the republishing metadata, keeping the record
475
+ * in the datastore so it is still served until it expires. Pass
476
+ * `removeRecord: true` to also delete the record so the node stops serving it.
477
+ * If a key name is passed, the key remains in the keychain.
478
+ */
479
+ unpublish(key: CID<unknown, 0x72> | PublicKey | MultihashDigest | string, options?: UnpublishOptions): Promise<void>
480
+
481
+ /**
482
+ * Republish the record already in the local store, setting its upkeep policy.
483
+ *
484
+ * Sets the record's upkeep policy to `options.upkeep` (default `rebroadcast`)
485
+ * so the background republisher keeps it available on the routers.
486
+ *
487
+ * @throws {NotFoundError} when there is no local record to republish
488
+ * @throws {RecordObsoleteError} when the routers already have a newer record
489
+ */
490
+ republish(key: CID<unknown, 0x72> | PublicKey | MultihashDigest | string, options?: RepublishOptions): Promise<RepublishResult>
361
491
 
362
492
  /**
363
- * Stop republishing of an IPNS record.
493
+ * Import an existing IPNS record into the local store without publishing it.
494
+ *
495
+ * The record is validated and stored so this node serves it on a GET, but it
496
+ * is not broadcast to the routers. A newly imported record (for a key with no
497
+ * existing upkeep policy) is not automatically republished; call `republish`
498
+ * with an upkeep policy to start keeping it alive.
364
499
  *
365
- * This will delete the last signed IPNS record from the datastore.
500
+ * Overwrites an older record already stored under this key, or rejects with
501
+ * `RecordObsoleteError` if the stored record is newer. Importing never changes
502
+ * the key's upkeep metadata, so a key that already has an upkeep policy keeps
503
+ * it and continues to be republished automatically.
366
504
  *
367
- * Note that the record may still be resolved by other peers until it expires
368
- * or is otherwise no longer valid.
505
+ * @throws {RecordExpiredError} (or another validation error) when the record fails validation
506
+ * @throws {RecordObsoleteError} when the stored record is more suitable than the one being imported
369
507
  */
370
- unpublish(keyName: string, options?: AbortOptions): Promise<void>
508
+ import(key: CID<unknown, 0x72> | PublicKey | MultihashDigest | string, record: IPNSEntry | Uint8Array, options?: AbortOptions): Promise<void>
371
509
  }
372
510
 
373
511
  export interface IPNSComponents {
@@ -2,9 +2,10 @@ import { base36 } from 'multiformats/bases/base36'
2
2
  import { CustomProgressEvent } from 'progress-events'
3
3
  import { DEFAULT_LIFETIME_MS } from '../constants.ts'
4
4
  import { IPNSEntry } from '../pb/ipns.ts'
5
+ import { Upkeep } from '../pb/metadata.ts'
5
6
  import { createIPNSRecord } from '../records.ts'
6
7
  import { decodeExtensibleData, multihashToIPNSRoutingKey } from '../utils.ts'
7
- import type { IPNSPublishResult, PublishOptions } from '../index.ts'
8
+ import type { IPNSPublishResult, PublishOptions, UnpublishOptions } from '../index.ts'
8
9
  import type { LocalStore } from '../local-store.ts'
9
10
  import type { IPNSRouting } from '../routing/index.ts'
10
11
  import type { Keychain, PrivateKey, PublicKey } from '@helia/interface'
@@ -58,25 +59,14 @@ export class IPNSPublisher {
58
59
  })
59
60
  const marshaledRecord = IPNSEntry.encode(record)
60
61
 
62
+ const metadata = { keyName, lifetime, upkeep: Upkeep[options.upkeep ?? 'reissue'] }
61
63
  if (options.offline === true) {
62
64
  // only store record locally
63
- await this.localStore.put(routingKey, marshaledRecord, {
64
- ...options,
65
- metadata: {
66
- keyName,
67
- lifetime
68
- }
69
- })
65
+ await this.localStore.put(routingKey, marshaledRecord, { ...options, metadata })
70
66
  } else {
71
67
  // publish record to routing (including the local store)
72
68
  await Promise.all(this.routers.map(async r => {
73
- await r.put(routingKey, marshaledRecord, {
74
- ...options,
75
- metadata: {
76
- keyName,
77
- lifetime
78
- }
79
- })
69
+ await r.put(routingKey, marshaledRecord, { ...options, metadata })
80
70
  }))
81
71
  }
82
72
 
@@ -108,10 +98,22 @@ export class IPNSPublisher {
108
98
  }
109
99
  }
110
100
 
111
- async unpublish (keyName: string, options?: AbortOptions): Promise<void> {
112
- const key = await this.keychain.exportKey(keyName, options)
113
- const digest = key.publicKey.toMultihash()
114
- const routingKey = multihashToIPNSRoutingKey(digest)
115
- await this.localStore.delete(routingKey, options)
101
+ async unpublish (keyName: string | MultihashDigest, options?: UnpublishOptions): Promise<void> {
102
+ if (typeof keyName === 'string') {
103
+ const key = await this.keychain.exportKey(keyName, options)
104
+ keyName = key.publicKey.toMultihash()
105
+ }
106
+
107
+ const routingKey = multihashToIPNSRoutingKey(keyName)
108
+
109
+ if (options?.removeRecord === true) {
110
+ // delete the record and its metadata so this node stops serving it
111
+ await this.localStore.delete(routingKey, options)
112
+ return
113
+ }
114
+
115
+ // remove only the metadata so the record is still served but no longer
116
+ // automatically republished; it expires naturally at its validity
117
+ await this.localStore.deleteMetadata(routingKey, options)
116
118
  }
117
119
  }
@@ -1,15 +1,24 @@
1
+ import { NotFoundError } from '@libp2p/interface'
1
2
  import { Queue, repeatingTask } from '@libp2p/utils'
3
+ import NanoDate from 'timestamp-nano'
2
4
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
3
5
  import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts'
6
+ import { RecordObsoleteError } from '../errors.ts'
4
7
  import { IPNSEntry } from '../pb/ipns.ts'
8
+ import { Upkeep } from '../pb/metadata.ts'
5
9
  import { createIPNSRecord } from '../records.ts'
6
- import { decodeExtensibleData, shouldRepublish } from '../utils.ts'
7
- import type { IPNSRecordData } from '../index.ts'
10
+ import { isLocalStoreRouting, routerName } from '../routing/index.ts'
11
+ import { ipnsSelector } from '../selector.ts'
12
+ import { decodeExtensibleData, multihashToIPNSRoutingKey, shouldRepublish } from '../utils.ts'
13
+ import { extractPublicKey, ipnsValidator } from '../validator.ts'
14
+ import { findRoutingRecords } from './resolver.ts'
15
+ import type { IPNSRecordData, RepublishOptions, RepublishResult } from '../index.ts'
8
16
  import type { LocalStore } from '../local-store.ts'
9
- import type { IPNSRouting } from '../routing/index.ts'
17
+ import type { IPNSRouting, IPNSRoutingPutOptions } from '../routing/index.ts'
10
18
  import type { Keychain, PrivateKey } from '@helia/interface'
11
19
  import type { AbortOptions, ComponentLogger, Logger } from '@libp2p/interface'
12
20
  import type { RepeatingTask } from '@libp2p/utils'
21
+ import type { MultihashDigest } from 'multiformats'
13
22
 
14
23
  export interface IPNSRepublisherComponents {
15
24
  logger: ComponentLogger
@@ -79,7 +88,8 @@ export class IPNSRepublisher {
79
88
  })
80
89
 
81
90
  try {
82
- const recordsToRepublish: Array<{ routingKey: Uint8Array, record: IPNSEntry }> = []
91
+ const recordsToReissue: Array<{ routingKey: Uint8Array, record: IPNSEntry }> = []
92
+ const recordsToRebroadcast: Array<{ routingKey: Uint8Array, record: IPNSEntry }> = []
83
93
  let listed = 0
84
94
 
85
95
  // Find all records using the localStore.list method
@@ -87,23 +97,31 @@ export class IPNSRepublisher {
87
97
  listed++
88
98
 
89
99
  if (metadata == null) {
90
- // Skip if no metadata is found from before we started
91
- // storing metadata or for records republished without a key
100
+ // no metadata: an imported record not yet given an upkeep policy, or a
101
+ // record stored before we tracked metadata
92
102
  this.log('no metadata found for record %b, skipping', routingKey)
93
103
  continue
94
104
  }
95
105
 
106
+ if (metadata.upkeep === Upkeep.none) {
107
+ // Skip republishing, disabled for this record
108
+ this.log('republishing is disabled for record %b, skipping', routingKey)
109
+ continue
110
+ }
111
+
96
112
  let ipnsRecord: IPNSEntry
97
113
 
98
114
  try {
99
115
  ipnsRecord = IPNSEntry.decode(record)
100
116
  } catch (err) {
101
- this.log.trace('skipping invalid record %b because could not decode', routingKey)
117
+ // the record was valid when stored, so a decode failure now means
118
+ // on-disk corruption or a format change worth surfacing
119
+ this.log.error('skipping stored record %b, could not decode it - %e', routingKey, err)
102
120
  continue
103
121
  }
104
122
 
105
123
  if (ipnsRecord.data == null) {
106
- this.log.trace('skipping record %b because data was missing', routingKey)
124
+ this.log.error('skipping stored record %b, its data is missing', routingKey)
107
125
  continue
108
126
  }
109
127
 
@@ -111,17 +129,40 @@ export class IPNSRepublisher {
111
129
 
112
130
  try {
113
131
  data = decodeExtensibleData(ipnsRecord.data)
114
- } catch {
115
- this.log.trace('skipping record %b because could not decode data', routingKey)
132
+ } catch (err) {
133
+ this.log.error('skipping stored record %b, could not decode its data - %e', routingKey, err)
134
+ continue
135
+ }
136
+
137
+ let recordExpiry: Date
138
+
139
+ try {
140
+ recordExpiry = NanoDate.fromString(uint8ArrayToString(data.Validity)).toDate()
141
+ } catch (err) {
142
+ this.log.error('skipping stored record %b, could not parse its validity - %e', routingKey, err)
116
143
  continue
117
144
  }
118
145
 
119
146
  // Only republish records that are within the DHT or record expiry threshold
120
- if (!shouldRepublish(created, new Date(uint8ArrayToString(data.Validity)))) {
147
+ if (!shouldRepublish(created, recordExpiry)) {
121
148
  this.log.trace('skipping record %b within republish threshold', routingKey)
122
149
  continue
123
150
  }
124
151
 
152
+ if (metadata.upkeep === Upkeep.rebroadcast) {
153
+ // only re-broadcast records that are still valid: an expired record
154
+ // cannot be revived without re-signing, which the rebroadcast policy
155
+ // has no key for
156
+ if (recordExpiry.getTime() <= Date.now()) {
157
+ this.log.trace('skipping expired record %b for rebroadcast', routingKey)
158
+ continue
159
+ }
160
+
161
+ recordsToRebroadcast.push({ routingKey, record: ipnsRecord })
162
+ continue
163
+ }
164
+
165
+ // Upkeep.reissue: re-sign the record with a new validity
125
166
  const sequenceNumber = data.Sequence + 1n
126
167
  const ttlNs = data.TTL ?? DEFAULT_TTL_NS
127
168
  let privKey: PrivateKey
@@ -138,36 +179,151 @@ export class IPNSRepublisher {
138
179
  ...options,
139
180
  ttlNs
140
181
  })
141
- recordsToRepublish.push({
182
+
183
+ recordsToReissue.push({
142
184
  routingKey,
143
185
  record: updatedRecord
144
186
  })
145
187
  } catch (err: any) {
146
- this.log.error('error creating updated IPNS record for %s - %e', routingKey, err)
188
+ this.log.error('error creating updated IPNS record for %b - %e', routingKey, err)
147
189
  continue
148
190
  }
149
191
  }
150
192
 
151
- this.log(`found ${recordsToRepublish.length}/${listed} records to republish`)
193
+ this.log(`reissuing ${recordsToReissue.length} and rebroadcasting ${recordsToRebroadcast.length} of ${listed} records`)
194
+
195
+ // reissue: re-publish the re-signed record as-is. the jobs run
196
+ // fire-and-forget, so catch any failure (including a local-store write
197
+ // that #publishToRouters surfaces) rather than leaving an unhandled rejection
198
+ for (const { routingKey, record } of recordsToReissue) {
199
+ queue.add(async () => this.#publishToRouters(routingKey, IPNSEntry.encode(record), options), options)
200
+ .catch((err: any) => { this.log.error('failed to reissue record %b - %e', routingKey, err) })
201
+ }
152
202
 
153
- // Republish each record
154
- for (const { routingKey, record } of recordsToRepublish) {
155
- // Add job to queue to republish the record to all routers
203
+ // rebroadcast: resolve the latest record from the network and re-broadcast
204
+ // whichever of the local and network records is most suitable, so we adopt
205
+ // a newer record rather than pushing a stale local copy
206
+ for (const { routingKey, record } of recordsToRebroadcast) {
156
207
  queue.add(async () => {
157
- try {
158
- const marshaledRecord = IPNSEntry.encode(record)
159
- await Promise.all(
160
- this.routers.map(r => r.put(routingKey, marshaledRecord, options))
161
- )
162
- } catch (err: any) {
163
- this.log.error('error republishing record - %e', err)
164
- }
208
+ const { records } = await findRoutingRecords(this.routers, routingKey, this.keychain, this.log, options)
209
+ const candidates = [record, ...records]
210
+ const best = candidates[ipnsSelector(routingKey, candidates)]
211
+
212
+ await this.#publishToRouters(routingKey, IPNSEntry.encode(best), { ...options, overwrite: true })
165
213
  }, options)
214
+ .catch((err: any) => { this.log.error('failed to rebroadcast record %b - %e', routingKey, err) })
166
215
  }
167
216
  } catch (err: any) {
168
217
  this.log.error('error during republish - %e', err)
169
218
  }
219
+ }
220
+
221
+ /**
222
+ * Persist the record to the local store first, throwing if that fails: it
223
+ * holds the record and its upkeep policy, so that failure must surface rather
224
+ * than be swallowed. Then broadcast to the network routers best-effort,
225
+ * logging any that fail.
226
+ */
227
+ async #publishToRouters (routingKey: Uint8Array, marshaledRecord: Uint8Array, putOptions: IPNSRoutingPutOptions): Promise<void> {
228
+ const localStoreRouter = this.routers.find(isLocalStoreRouting)
229
+
230
+ if (localStoreRouter != null) {
231
+ await localStoreRouter.put(routingKey, marshaledRecord, putOptions)
232
+ this.log('published record %b to %s', routingKey, routerName(localStoreRouter))
233
+ }
234
+
235
+ await Promise.all(
236
+ this.routers
237
+ .filter(router => router !== localStoreRouter)
238
+ .map(async (router) => {
239
+ try {
240
+ await router.put(routingKey, marshaledRecord, putOptions)
241
+ this.log('published record %b to %s', routingKey, routerName(router))
242
+ } catch (err) {
243
+ this.log.error('failed to publish record %b to %s - %e', routingKey, routerName(router), err)
244
+ }
245
+ })
246
+ )
247
+ }
248
+
249
+ async republish (key: MultihashDigest, options: RepublishOptions = {}): Promise<RepublishResult> {
250
+ const routingKey = multihashToIPNSRoutingKey(key)
251
+
252
+ // read the record we hold for this key directly, it was validated when it
253
+ // was imported or published, and reading it through resolve() would recurse
254
+ // and rewrite the local store
255
+ let record: Uint8Array<ArrayBuffer>
256
+
257
+ try {
258
+ ({ record } = await this.localStore.get(routingKey, options))
259
+ } catch (err: any) {
260
+ if (err.name !== 'NotFoundError') {
261
+ throw err
262
+ }
263
+
264
+ throw new NotFoundError('No local record found to republish - import it first')
265
+ }
266
+
267
+ // re-validate: a record that was valid when stored can have expired since
268
+ const localRecord = await ipnsValidator(routingKey, record, this.keychain, options)
269
+
270
+ // unless we are told not to look, check whether the routing already has a
271
+ // more suitable record; if so, back off
272
+ if (options.skipResolution !== true) {
273
+ const { records } = await findRoutingRecords(this.routers, routingKey, this.keychain, this.log, options)
274
+ const candidates = [localRecord, ...records]
275
+ const best = ipnsSelector(routingKey, candidates)
276
+
277
+ if (best !== 0) {
278
+ throw new RecordObsoleteError('A newer record is already published for this key', candidates[best])
279
+ }
280
+ }
281
+
282
+ const metadata = {
283
+ upkeep: Upkeep[options.upkeep ?? 'rebroadcast']
284
+ }
285
+
286
+ // a publish that fails on every router does not throw; use onProgress to see
287
+ // which routers worked and watch the logs
288
+ await this.#publishToRouters(routingKey, record, {
289
+ ...options,
290
+ overwrite: true,
291
+ metadata
292
+ })
293
+
294
+ return {
295
+ record: localRecord,
296
+ publicKey: await extractPublicKey(routingKey, localRecord, this.keychain, options)
297
+ }
298
+ }
299
+
300
+ async import (key: MultihashDigest, record: IPNSEntry | Uint8Array, options?: AbortOptions): Promise<void> {
301
+ const routingKey = multihashToIPNSRoutingKey(key)
302
+ const entry = record instanceof Uint8Array ? IPNSEntry.decode(record) : record
303
+ const marshaledRecord = IPNSEntry.encode(entry)
304
+
305
+ // validate the record, throws if it is invalid (bad signature, expired, etc)
306
+ await ipnsValidator(routingKey, marshaledRecord, this.keychain, options)
307
+
308
+ // do not downgrade a record we already hold
309
+ try {
310
+ const { record: existingBytes } = await this.localStore.get(routingKey, options)
311
+ const existing = IPNSEntry.decode(existingBytes)
312
+
313
+ // if the record we already have wins the selector, the incoming one is obsolete
314
+ if (ipnsSelector(routingKey, [entry, existing]) !== 0) {
315
+ throw new RecordObsoleteError('A newer record is already stored for this key', existing)
316
+ }
317
+ } catch (err: any) {
318
+ // holding no record for this key is the normal case, everything else
319
+ // (the RecordObsoleteError above included) is a real failure
320
+ if (err.name !== 'NotFoundError') {
321
+ throw err
322
+ }
323
+ }
170
324
 
171
- await queue.onIdle(options) // Wait for all jobs to complete
325
+ // store the record locally only, without metadata, so it is served but not
326
+ // automatically republished until republish() gives it an upkeep policy
327
+ await this.localStore.put(routingKey, marshaledRecord, options)
172
328
  }
173
329
  }