@helia/ipns 9.0.0 → 9.1.0-3eb6f3a7

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.
@@ -0,0 +1,144 @@
1
+ import { Queue, repeatingTask } from '@libp2p/utils'
2
+ import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord } from 'ipns'
3
+ import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts'
4
+ import { shouldRepublish } from '../utils.js'
5
+ import type { LocalStore } from '../local-store.js'
6
+ import type { IPNSRouting } from '../routing/index.js'
7
+ import type { AbortOptions, ComponentLogger, Libp2p, Logger, PrivateKey } from '@libp2p/interface'
8
+ import type { Keychain } from '@libp2p/keychain'
9
+ import type { RepeatingTask } from '@libp2p/utils'
10
+ import type { IPNSRecord } from 'ipns'
11
+
12
+ export interface IPNSRepublisherComponents {
13
+ logger: ComponentLogger
14
+ libp2p: Libp2p<{ keychain: Keychain }>
15
+ }
16
+
17
+ export interface IPNSRepublisherInit {
18
+ republishConcurrency?: number
19
+ republishInterval?: number
20
+ routers: IPNSRouting[]
21
+ localStore: LocalStore
22
+ }
23
+
24
+ export class IPNSRepublisher {
25
+ public readonly routers: IPNSRouting[]
26
+ private readonly localStore: LocalStore
27
+ private readonly republishTask: RepeatingTask
28
+ private readonly log: Logger
29
+ private readonly keychain: Keychain
30
+ private started: boolean = false
31
+ private readonly republishConcurrency: number
32
+
33
+ constructor (components: IPNSRepublisherComponents, init: IPNSRepublisherInit) {
34
+ this.log = components.logger.forComponent('helia:ipns')
35
+ this.localStore = init.localStore
36
+ this.keychain = components.libp2p.services.keychain
37
+ this.republishConcurrency = init.republishConcurrency || DEFAULT_REPUBLISH_CONCURRENCY
38
+ this.started = components.libp2p.status === 'started'
39
+ this.routers = init.routers ?? []
40
+
41
+ this.republishTask = repeatingTask(this.#republish.bind(this), init.republishInterval ?? DEFAULT_REPUBLISH_INTERVAL_MS, {
42
+ runImmediately: true
43
+ })
44
+
45
+ if (this.started) {
46
+ this.republishTask.start()
47
+ }
48
+ }
49
+
50
+ start (): void {
51
+ if (this.started) {
52
+ return
53
+ }
54
+
55
+ this.started = true
56
+ this.republishTask.start()
57
+ }
58
+
59
+ stop (): void {
60
+ if (!this.started) {
61
+ return
62
+ }
63
+
64
+ this.started = false
65
+ this.republishTask.stop()
66
+ }
67
+
68
+ async #republish (options: AbortOptions = {}): Promise<void> {
69
+ if (!this.started) {
70
+ return
71
+ }
72
+
73
+ this.log('starting ipns republish records loop')
74
+
75
+ const queue = new Queue({
76
+ concurrency: this.republishConcurrency
77
+ })
78
+
79
+ try {
80
+ const recordsToRepublish: Array<{ routingKey: Uint8Array, record: IPNSRecord }> = []
81
+
82
+ // Find all records using the localStore.list method
83
+ for await (const { routingKey, record, metadata, created } of this.localStore.list(options)) {
84
+ if (metadata == null) {
85
+ // Skip if no metadata is found from before we started
86
+ // storing metadata or for records republished without a key
87
+ this.log(`no metadata found for record ${routingKey.toString()}, skipping`)
88
+ continue
89
+ }
90
+ let ipnsRecord: IPNSRecord
91
+ try {
92
+ ipnsRecord = unmarshalIPNSRecord(record)
93
+ } catch (err: any) {
94
+ this.log.error('error unmarshaling record - %e', err)
95
+ continue
96
+ }
97
+
98
+ // Only republish records that are within the DHT or record expiry threshold
99
+ if (!shouldRepublish(ipnsRecord, created)) {
100
+ this.log.trace(`skipping record ${routingKey.toString()}within republish threshold`)
101
+ continue
102
+ }
103
+ const sequenceNumber = ipnsRecord.sequence + 1n
104
+ const ttlNs = ipnsRecord.ttl ?? DEFAULT_TTL_NS
105
+ let privKey: PrivateKey
106
+
107
+ try {
108
+ privKey = await this.keychain.exportKey(metadata.keyName)
109
+ } catch (err: any) {
110
+ this.log.error(`missing key ${metadata.keyName}, skipping republishing record`, err)
111
+ continue
112
+ }
113
+ try {
114
+ const updatedRecord = await createIPNSRecord(privKey, ipnsRecord.value, sequenceNumber, metadata.lifetime, { ...options, ttlNs })
115
+ recordsToRepublish.push({ routingKey, record: updatedRecord })
116
+ } catch (err: any) {
117
+ this.log.error(`error creating updated IPNS record for ${routingKey.toString()}`, err)
118
+ continue
119
+ }
120
+ }
121
+
122
+ this.log(`found ${recordsToRepublish.length} records to republish`)
123
+
124
+ // Republish each record
125
+ for (const { routingKey, record } of recordsToRepublish) {
126
+ // Add job to queue to republish the record to all routers
127
+ queue.add(async () => {
128
+ try {
129
+ const marshaledRecord = marshalIPNSRecord(record)
130
+ await Promise.all(
131
+ this.routers.map(r => r.put(routingKey, marshaledRecord, options))
132
+ )
133
+ } catch (err: any) {
134
+ this.log.error('error republishing record - %e', err)
135
+ }
136
+ }, options)
137
+ }
138
+ } catch (err: any) {
139
+ this.log.error('error during republish - %e', err)
140
+ }
141
+
142
+ await queue.onIdle(options) // Wait for all jobs to complete
143
+ }
144
+ }
@@ -0,0 +1,217 @@
1
+ import { NotFoundError, isPeerId, isPublicKey } from '@libp2p/interface'
2
+ import { multihashToIPNSRoutingKey, unmarshalIPNSRecord } from 'ipns'
3
+ import { ipnsSelector } from 'ipns/selector'
4
+ import { ipnsValidator } from 'ipns/validator'
5
+ import { base36 } from 'multiformats/bases/base36'
6
+ import { base58btc } from 'multiformats/bases/base58'
7
+ import { CID } from 'multiformats/cid'
8
+ import * as Digest from 'multiformats/hashes/digest'
9
+ import { DEFAULT_TTL_NS } from '../constants.ts'
10
+ import { InvalidValueError, RecordsFailedValidationError, UnsupportedMultibasePrefixError, UnsupportedMultihashCodecError } from '../errors.js'
11
+ import { isCodec, IDENTITY_CODEC, SHA2_256_CODEC, isLibp2pCID } from '../utils.js'
12
+ import type { IPNSResolveResult, ResolveOptions, ResolveResult } from '../index.js'
13
+ import type { LocalStore } from '../local-store.js'
14
+ import type { IPNSRouting } from '../routing/index.js'
15
+ import type { Routing } from '@helia/interface'
16
+ import type { ComponentLogger, Logger, PeerId, PublicKey } from '@libp2p/interface'
17
+ import type { Datastore } from 'interface-datastore'
18
+ import type { IPNSRecord } from 'ipns'
19
+ import type { MultibaseDecoder } from 'multiformats/bases/interface'
20
+ import type { MultihashDigest } from 'multiformats/hashes/interface'
21
+
22
+ const bases: Record<string, MultibaseDecoder<string>> = {
23
+ [base36.prefix]: base36,
24
+ [base58btc.prefix]: base58btc
25
+ }
26
+
27
+ export interface IPNSResolverComponents {
28
+ datastore: Datastore
29
+ routing: Routing
30
+ logger: ComponentLogger
31
+ }
32
+
33
+ export interface IPNResolverInit {
34
+ localStore: LocalStore
35
+ routers: IPNSRouting[]
36
+ }
37
+
38
+ export class IPNSResolver {
39
+ public readonly routers: IPNSRouting[]
40
+ private readonly localStore: LocalStore
41
+ private readonly log: Logger
42
+
43
+ constructor (components: IPNSResolverComponents, init: IPNResolverInit) {
44
+ this.log = components.logger.forComponent('helia:ipns')
45
+ this.localStore = init.localStore
46
+ this.routers = init.routers
47
+ }
48
+
49
+ async resolve (key: CID<unknown, 0x72, 0x00 | 0x12, 1> | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: ResolveOptions = {}): Promise<IPNSResolveResult> {
50
+ const digest = isPublicKey(key) || isPeerId(key) ? key.toMultihash() : isLibp2pCID(key) ? key.multihash : key
51
+ const routingKey = multihashToIPNSRoutingKey(digest)
52
+ const record = await this.#findIpnsRecord(routingKey, options)
53
+
54
+ return {
55
+ ...(await this.#resolve(record.value, options)),
56
+ record
57
+ }
58
+ }
59
+
60
+ async #resolve (ipfsPath: string, options: ResolveOptions = {}): Promise<ResolveResult> {
61
+ const parts = ipfsPath.split('/')
62
+ try {
63
+ const scheme = parts[1]
64
+
65
+ if (scheme === 'ipns') {
66
+ const str = parts[2]
67
+ const prefix = str.substring(0, 1)
68
+ let buf: Uint8Array | undefined
69
+
70
+ if (prefix === '1' || prefix === 'Q') {
71
+ buf = base58btc.decode(`z${str}`)
72
+ } else if (bases[prefix] != null) {
73
+ buf = bases[prefix].decode(str)
74
+ } else {
75
+ throw new UnsupportedMultibasePrefixError(`Unsupported multibase prefix "${prefix}"`)
76
+ }
77
+
78
+ let digest: MultihashDigest<number>
79
+
80
+ try {
81
+ digest = Digest.decode(buf)
82
+ } catch {
83
+ digest = CID.decode(buf).multihash
84
+ }
85
+
86
+ if (!isCodec(digest, IDENTITY_CODEC) && !isCodec(digest, SHA2_256_CODEC)) {
87
+ throw new UnsupportedMultihashCodecError(`Unsupported multihash codec "${digest.code}"`)
88
+ }
89
+
90
+ const { cid } = await this.resolve(digest, options)
91
+ const path = parts.slice(3).join('/')
92
+
93
+ return {
94
+ cid,
95
+ path: path === '' ? undefined : path
96
+ }
97
+ } else if (scheme === 'ipfs') {
98
+ const cid = CID.parse(parts[2])
99
+ const path = parts.slice(3).join('/')
100
+
101
+ return {
102
+ cid,
103
+ path: path === '' ? undefined : path
104
+ }
105
+ }
106
+ } catch (err) {
107
+ this.log.error('error parsing ipfs path - %e', err)
108
+ }
109
+
110
+ this.log.error('invalid ipfs path %s', ipfsPath)
111
+ throw new InvalidValueError('Invalid value')
112
+ }
113
+
114
+ async #findIpnsRecord (routingKey: Uint8Array, options: ResolveOptions = {}): Promise<IPNSRecord> {
115
+ const records: Uint8Array[] = []
116
+ const cached = await this.localStore.has(routingKey, options)
117
+
118
+ if (cached) {
119
+ this.log('record is present in the cache')
120
+
121
+ if (options.nocache !== true) {
122
+ try {
123
+ // check the local cache first
124
+ const { record, created } = await this.localStore.get(routingKey, options)
125
+
126
+ this.log('record retrieved from cache')
127
+
128
+ // validate the record
129
+ await ipnsValidator(routingKey, record)
130
+
131
+ this.log('record was valid')
132
+
133
+ // check the TTL
134
+ const ipnsRecord = unmarshalIPNSRecord(record)
135
+
136
+ // IPNS TTL is in nanoseconds, convert to milliseconds, default to one
137
+ // hour
138
+ const ttlMs = Number((ipnsRecord.ttl ?? DEFAULT_TTL_NS) / 1_000_000n)
139
+ const ttlExpires = created.getTime() + ttlMs
140
+
141
+ if (ttlExpires > Date.now()) {
142
+ // the TTL has not yet expired, return the cached record
143
+ this.log('record TTL was valid')
144
+ return ipnsRecord
145
+ }
146
+
147
+ if (options.offline === true) {
148
+ // the TTL has expired but we are skipping the routing search
149
+ this.log('record TTL has been reached but we are resolving offline-only, returning record')
150
+ return ipnsRecord
151
+ }
152
+
153
+ this.log('record TTL has been reached, searching routing for updates')
154
+
155
+ // add the local record to our list of resolved record, and also
156
+ // search the routing for updates - the most up to date record will be
157
+ // returned
158
+ records.push(record)
159
+ } catch (err) {
160
+ this.log('cached record was invalid - %e', err)
161
+ await this.localStore.delete(routingKey, options)
162
+ }
163
+ } else {
164
+ this.log('ignoring local cache due to nocache=true option')
165
+ }
166
+ }
167
+
168
+ if (options.offline === true) {
169
+ throw new NotFoundError('Record was not present in the cache or has expired')
170
+ }
171
+
172
+ this.log('did not have record locally')
173
+
174
+ let foundInvalid = 0
175
+
176
+ await Promise.all(
177
+ this.routers.map(async (router) => {
178
+ let record: Uint8Array
179
+
180
+ try {
181
+ record = await router.get(routingKey, {
182
+ ...options,
183
+ validate: false
184
+ })
185
+ } catch (err: any) {
186
+ this.log.error('error finding IPNS record - %e', err)
187
+
188
+ return
189
+ }
190
+
191
+ try {
192
+ await ipnsValidator(routingKey, record)
193
+
194
+ records.push(record)
195
+ } catch (err) {
196
+ // we found a record, but the validator rejected it
197
+ foundInvalid++
198
+ this.log.error('error finding IPNS record - %e', err)
199
+ }
200
+ })
201
+ )
202
+
203
+ if (records.length === 0) {
204
+ if (foundInvalid > 0) {
205
+ throw new RecordsFailedValidationError(`${foundInvalid > 1 ? `${foundInvalid} records` : 'Record'} found for routing key ${foundInvalid > 1 ? 'were' : 'was'} invalid`)
206
+ }
207
+
208
+ throw new NotFoundError('Could not find record for routing key')
209
+ }
210
+
211
+ const record = records[ipnsSelector(routingKey, records)]
212
+
213
+ await this.localStore.put(routingKey, record, options)
214
+
215
+ return unmarshalIPNSRecord(record)
216
+ }
217
+ }