@helia/ipns 0.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.
- package/LICENSE +4 -0
- package/README.md +59 -0
- package/dist/index.min.js +25 -0
- package/dist/src/index.d.ts +124 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +192 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/routing/dht.d.ts +18 -0
- package/dist/src/routing/dht.d.ts.map +1 -0
- package/dist/src/routing/dht.js +65 -0
- package/dist/src/routing/dht.js.map +1 -0
- package/dist/src/routing/index.d.ts +17 -0
- package/dist/src/routing/index.d.ts.map +1 -0
- package/dist/src/routing/index.js +3 -0
- package/dist/src/routing/index.js.map +1 -0
- package/dist/src/routing/local-store.d.ts +15 -0
- package/dist/src/routing/local-store.d.ts.map +1 -0
- package/dist/src/routing/local-store.js +48 -0
- package/dist/src/routing/local-store.js.map +1 -0
- package/dist/src/routing/pubsub.d.ts +20 -0
- package/dist/src/routing/pubsub.d.ts.map +1 -0
- package/dist/src/routing/pubsub.js +150 -0
- package/dist/src/routing/pubsub.js.map +1 -0
- package/dist/src/utils/resolve-dns-link.browser.d.ts +6 -0
- package/dist/src/utils/resolve-dns-link.browser.d.ts.map +1 -0
- package/dist/src/utils/resolve-dns-link.browser.js +46 -0
- package/dist/src/utils/resolve-dns-link.browser.js.map +1 -0
- package/dist/src/utils/resolve-dns-link.d.ts +3 -0
- package/dist/src/utils/resolve-dns-link.d.ts.map +1 -0
- package/dist/src/utils/resolve-dns-link.js +54 -0
- package/dist/src/utils/resolve-dns-link.js.map +1 -0
- package/dist/src/utils/tlru.d.ts +15 -0
- package/dist/src/utils/tlru.d.ts.map +1 -0
- package/dist/src/utils/tlru.js +39 -0
- package/dist/src/utils/tlru.js.map +1 -0
- package/package.json +191 -0
- package/src/index.ts +296 -0
- package/src/routing/dht.ts +85 -0
- package/src/routing/index.ts +26 -0
- package/src/routing/local-store.ts +63 -0
- package/src/routing/pubsub.ts +195 -0
- package/src/utils/resolve-dns-link.browser.ts +61 -0
- package/src/utils/resolve-dns-link.ts +65 -0
- package/src/utils/tlru.ts +52 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
*
|
|
4
|
+
* IPNS operations using a Helia node
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
*
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { gossipsub } from '@chainsafe/libp2p'
|
|
10
|
+
* import { kadDHT } from '@libp2p/kad-dht'
|
|
11
|
+
* import { createLibp2p } from 'libp2p'
|
|
12
|
+
* import { createHelia } from 'helia'
|
|
13
|
+
* import { ipns, ipnsValidator, ipnsSelector } from '@helia/ipns'
|
|
14
|
+
* import { dht, pubsub } from '@helia/ipns/routing'
|
|
15
|
+
* import { unixfs } from '@helia/unixfs
|
|
16
|
+
*
|
|
17
|
+
* const libp2p = await createLibp2p({
|
|
18
|
+
* dht: kadDHT({
|
|
19
|
+
* validators: {
|
|
20
|
+
* ipns: ipnsValidator
|
|
21
|
+
* },
|
|
22
|
+
* selectors: {
|
|
23
|
+
* ipns: ipnsSelector
|
|
24
|
+
* }
|
|
25
|
+
* }),
|
|
26
|
+
* pubsub: gossipsub()
|
|
27
|
+
* })
|
|
28
|
+
*
|
|
29
|
+
* const helia = await createHelia({
|
|
30
|
+
* libp2p,
|
|
31
|
+
* //.. other options
|
|
32
|
+
* })
|
|
33
|
+
* const name = ipns(helia, [
|
|
34
|
+
* dht(helia)
|
|
35
|
+
* pubsub(helia)
|
|
36
|
+
* ])
|
|
37
|
+
*
|
|
38
|
+
* // create a public key to publish as an IPNS name
|
|
39
|
+
* const keyInfo = await helia.libp2p.keychain.createKey('my-key')
|
|
40
|
+
* const peerId = await helia.libp2p.keychain.exportPeerId(keyInfo.name)
|
|
41
|
+
*
|
|
42
|
+
* // store some data to publish
|
|
43
|
+
* const fs = unixfs(helia)
|
|
44
|
+
* const cid = await fs.add(Uint8Array.from([0, 1, 2, 3, 4]))
|
|
45
|
+
*
|
|
46
|
+
* // publish the name
|
|
47
|
+
* await name.publish(peerId, cid)
|
|
48
|
+
*
|
|
49
|
+
* // resolve the name
|
|
50
|
+
* const cid = name.resolve(peerId)
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
*
|
|
55
|
+
* ```typescript
|
|
56
|
+
* // resolve a CID from a TXT record in a DNS zone file, eg:
|
|
57
|
+
* // > dig ipfs.io TXT
|
|
58
|
+
* // ;; ANSWER SECTION:
|
|
59
|
+
* // ipfs.io. 435 IN TXT "dnslink=/ipfs/Qmfoo"
|
|
60
|
+
*
|
|
61
|
+
* const cid = name.resolveDns('ipfs.io')
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import type { AbortOptions } from '@libp2p/interfaces'
|
|
66
|
+
import { isPeerId, PeerId } from '@libp2p/interface-peer-id'
|
|
67
|
+
import { create, marshal, peerIdToRoutingKey, unmarshal } from 'ipns'
|
|
68
|
+
import type { IPNSEntry } from 'ipns'
|
|
69
|
+
import type { IPNSRouting, IPNSRoutingEvents } from './routing/index.js'
|
|
70
|
+
import { ipnsValidator } from 'ipns/validator'
|
|
71
|
+
import { CID } from 'multiformats/cid'
|
|
72
|
+
import { resolveDnslink } from './utils/resolve-dns-link.js'
|
|
73
|
+
import { logger } from '@libp2p/logger'
|
|
74
|
+
import { peerIdFromString } from '@libp2p/peer-id'
|
|
75
|
+
import type { ProgressEvent, ProgressOptions } from 'progress-events'
|
|
76
|
+
import { CustomProgressEvent } from 'progress-events'
|
|
77
|
+
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
|
|
78
|
+
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
|
|
79
|
+
import type { Datastore } from 'interface-datastore'
|
|
80
|
+
import { localStore, LocalStore } from './routing/local-store.js'
|
|
81
|
+
|
|
82
|
+
const log = logger('helia:ipns')
|
|
83
|
+
|
|
84
|
+
const MINUTE = 60 * 1000
|
|
85
|
+
const HOUR = 60 * MINUTE
|
|
86
|
+
|
|
87
|
+
const DEFAULT_LIFETIME_MS = 24 * HOUR
|
|
88
|
+
const DEFAULT_REPUBLISH_INTERVAL_MS = 23 * HOUR
|
|
89
|
+
|
|
90
|
+
export type PublishProgressEvents =
|
|
91
|
+
ProgressEvent<'ipns:publish:start'> |
|
|
92
|
+
ProgressEvent<'ipns:publish:success', IPNSEntry> |
|
|
93
|
+
ProgressEvent<'ipns:publish:error', Error>
|
|
94
|
+
|
|
95
|
+
export type ResolveProgressEvents =
|
|
96
|
+
ProgressEvent<'ipns:resolve:start', unknown> |
|
|
97
|
+
ProgressEvent<'ipns:resolve:success', IPNSEntry> |
|
|
98
|
+
ProgressEvent<'ipns:resolve:error', Error>
|
|
99
|
+
|
|
100
|
+
export type RepublishProgressEvents =
|
|
101
|
+
ProgressEvent<'ipns:republish:start', unknown> |
|
|
102
|
+
ProgressEvent<'ipns:republish:success', IPNSEntry> |
|
|
103
|
+
ProgressEvent<'ipns:republish:error', { record: IPNSEntry, err: Error }>
|
|
104
|
+
|
|
105
|
+
export interface PublishOptions extends AbortOptions, ProgressOptions<PublishProgressEvents | IPNSRoutingEvents> {
|
|
106
|
+
/**
|
|
107
|
+
* Time duration of the record in ms
|
|
108
|
+
*/
|
|
109
|
+
lifetime?: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ResolveOptions extends AbortOptions, ProgressOptions<ResolveProgressEvents | IPNSRoutingEvents> {
|
|
113
|
+
/**
|
|
114
|
+
* do not use cached entries
|
|
115
|
+
*/
|
|
116
|
+
nocache?: boolean
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface RepublishOptions extends AbortOptions, ProgressOptions<RepublishProgressEvents | IPNSRoutingEvents> {
|
|
120
|
+
/**
|
|
121
|
+
* The republish interval in ms (default: 24hrs)
|
|
122
|
+
*/
|
|
123
|
+
interval?: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface IPNS {
|
|
127
|
+
/**
|
|
128
|
+
* Creates an IPNS record signed by the passed PeerId that will resolve to the passed value
|
|
129
|
+
*
|
|
130
|
+
* If the valid is a PeerId, a recursive IPNS record will be created.
|
|
131
|
+
*/
|
|
132
|
+
publish: (key: PeerId, value: CID | PeerId, options?: PublishOptions) => Promise<IPNSEntry>
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Accepts a public key formatted as a libp2p PeerID and resolves the IPNS record
|
|
136
|
+
* corresponding to that public key until a value is found
|
|
137
|
+
*/
|
|
138
|
+
resolve: (key: PeerId, options?: ResolveOptions) => Promise<CID>
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve a CID from a dns-link style IPNS record
|
|
142
|
+
*/
|
|
143
|
+
resolveDns: (domain: string, options?: ResolveOptions) => Promise<CID>
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Periodically republish all IPNS records found in the datastore
|
|
147
|
+
*/
|
|
148
|
+
republish: (options?: RepublishOptions) => void
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type { IPNSRouting } from './routing/index.js'
|
|
152
|
+
|
|
153
|
+
export interface IPNSComponents {
|
|
154
|
+
datastore: Datastore
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
class DefaultIPNS implements IPNS {
|
|
158
|
+
private readonly routers: IPNSRouting[]
|
|
159
|
+
private readonly localStore: LocalStore
|
|
160
|
+
private timeout?: ReturnType<typeof setTimeout>
|
|
161
|
+
|
|
162
|
+
constructor (components: IPNSComponents, routers: IPNSRouting[] = []) {
|
|
163
|
+
this.routers = routers
|
|
164
|
+
this.localStore = localStore(components.datastore)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async publish (key: PeerId, value: CID | PeerId, options: PublishOptions = {}): Promise<IPNSEntry> {
|
|
168
|
+
try {
|
|
169
|
+
let sequenceNumber = 1n
|
|
170
|
+
const routingKey = peerIdToRoutingKey(key)
|
|
171
|
+
|
|
172
|
+
if (await this.localStore.has(routingKey, options)) {
|
|
173
|
+
// if we have published under this key before, increment the sequence number
|
|
174
|
+
const buf = await this.localStore.get(routingKey, options)
|
|
175
|
+
const existingRecord = unmarshal(buf)
|
|
176
|
+
sequenceNumber = existingRecord.sequence + 1n
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let str
|
|
180
|
+
|
|
181
|
+
if (isPeerId(value)) {
|
|
182
|
+
str = `/ipns/${value.toString()}`
|
|
183
|
+
} else {
|
|
184
|
+
str = `/ipfs/${value.toString()}`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const bytes = uint8ArrayFromString(str)
|
|
188
|
+
|
|
189
|
+
// create record
|
|
190
|
+
const record = await create(key, bytes, sequenceNumber, options.lifetime ?? DEFAULT_LIFETIME_MS)
|
|
191
|
+
const marshaledRecord = marshal(record)
|
|
192
|
+
|
|
193
|
+
await this.localStore.put(routingKey, marshaledRecord, options)
|
|
194
|
+
|
|
195
|
+
// publish record to routing
|
|
196
|
+
await Promise.all(this.routers.map(async r => { await r.put(routingKey, marshaledRecord, options) }))
|
|
197
|
+
|
|
198
|
+
return record
|
|
199
|
+
} catch (err: any) {
|
|
200
|
+
options.onProgress?.(new CustomProgressEvent<Error>('ipns:publish:error', err))
|
|
201
|
+
throw err
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async resolve (key: PeerId, options: ResolveOptions = {}): Promise<CID> {
|
|
206
|
+
const routingKey = peerIdToRoutingKey(key)
|
|
207
|
+
const record = await this.#findIpnsRecord(routingKey, options)
|
|
208
|
+
const str = uint8ArrayToString(record.value)
|
|
209
|
+
|
|
210
|
+
return await this.#resolve(str)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async resolveDns (domain: string, options: ResolveOptions = {}): Promise<CID> {
|
|
214
|
+
const dnslink = await resolveDnslink(domain, options)
|
|
215
|
+
|
|
216
|
+
return await this.#resolve(dnslink)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
republish (options: RepublishOptions = {}): void {
|
|
220
|
+
if (this.timeout != null) {
|
|
221
|
+
throw new Error('Republish is already running')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
options.signal?.addEventListener('abort', () => {
|
|
225
|
+
clearTimeout(this.timeout)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
async function republish (): Promise<void> {
|
|
229
|
+
const startTime = Date.now()
|
|
230
|
+
|
|
231
|
+
options.onProgress?.(new CustomProgressEvent('ipns:republish:start'))
|
|
232
|
+
|
|
233
|
+
const finishType = Date.now()
|
|
234
|
+
const timeTaken = finishType - startTime
|
|
235
|
+
let nextInterval = DEFAULT_REPUBLISH_INTERVAL_MS - timeTaken
|
|
236
|
+
|
|
237
|
+
if (nextInterval < 0) {
|
|
238
|
+
nextInterval = options.interval ?? DEFAULT_REPUBLISH_INTERVAL_MS
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
setTimeout(() => {
|
|
242
|
+
republish().catch(err => {
|
|
243
|
+
log.error('error republishing', err)
|
|
244
|
+
})
|
|
245
|
+
}, nextInterval)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
this.timeout = setTimeout(() => {
|
|
249
|
+
republish().catch(err => {
|
|
250
|
+
log.error('error republishing', err)
|
|
251
|
+
})
|
|
252
|
+
}, options.interval ?? DEFAULT_REPUBLISH_INTERVAL_MS)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async #resolve (ipfsPath: string): Promise<CID> {
|
|
256
|
+
const parts = ipfsPath.split('/')
|
|
257
|
+
|
|
258
|
+
if (parts.length === 3) {
|
|
259
|
+
const scheme = parts[1]
|
|
260
|
+
|
|
261
|
+
if (scheme === 'ipns') {
|
|
262
|
+
return await this.resolve(peerIdFromString(parts[2]))
|
|
263
|
+
} else if (scheme === 'ipfs') {
|
|
264
|
+
return CID.parse(parts[2])
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
log.error('invalid ipfs path %s', ipfsPath)
|
|
269
|
+
throw new Error('Invalid value')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async #findIpnsRecord (routingKey: Uint8Array, options: AbortOptions): Promise<IPNSEntry> {
|
|
273
|
+
const routers = [
|
|
274
|
+
this.localStore,
|
|
275
|
+
...this.routers
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
const unmarshaledRecord = await Promise.any(
|
|
279
|
+
routers.map(async (router) => {
|
|
280
|
+
const unmarshaledRecord = await router.get(routingKey, options)
|
|
281
|
+
await ipnsValidator(routingKey, unmarshaledRecord)
|
|
282
|
+
|
|
283
|
+
return unmarshaledRecord
|
|
284
|
+
})
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return unmarshal(unmarshaledRecord)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function ipns (components: IPNSComponents, routers: IPNSRouting[] = []): IPNS {
|
|
292
|
+
return new DefaultIPNS(components, routers)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export { ipnsValidator }
|
|
296
|
+
export { ipnsSelector } from 'ipns/selector'
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { logger } from '@libp2p/logger'
|
|
2
|
+
import type { IPNSRouting } from '../index.js'
|
|
3
|
+
import type { DHT, QueryEvent } from '@libp2p/interface-dht'
|
|
4
|
+
import type { GetOptions, PutOptions } from './index.js'
|
|
5
|
+
import { CustomProgressEvent, ProgressEvent } from 'progress-events'
|
|
6
|
+
|
|
7
|
+
const log = logger('helia:ipns:routing:dht')
|
|
8
|
+
|
|
9
|
+
export interface DHTRoutingComponents {
|
|
10
|
+
libp2p: {
|
|
11
|
+
dht: DHT
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type DHTProgressEvents =
|
|
16
|
+
ProgressEvent<'ipns:routing:dht:query', QueryEvent> |
|
|
17
|
+
ProgressEvent<'ipns:routing:dht:error', Error>
|
|
18
|
+
|
|
19
|
+
export class DHTRouting implements IPNSRouting {
|
|
20
|
+
private readonly dht: DHT
|
|
21
|
+
|
|
22
|
+
constructor (components: DHTRoutingComponents) {
|
|
23
|
+
this.dht = components.libp2p.dht
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async put (routingKey: Uint8Array, marshaledRecord: Uint8Array, options: PutOptions = {}): Promise<void> {
|
|
27
|
+
let putValue = false
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
for await (const event of this.dht.put(routingKey, marshaledRecord, options)) {
|
|
31
|
+
logEvent('DHT put event', event)
|
|
32
|
+
|
|
33
|
+
options.onProgress?.(new CustomProgressEvent<QueryEvent>('ipns:routing:dht:query', event))
|
|
34
|
+
|
|
35
|
+
if (event.name === 'PEER_RESPONSE' && event.messageName === 'PUT_VALUE') {
|
|
36
|
+
putValue = true
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch (err: any) {
|
|
40
|
+
options.onProgress?.(new CustomProgressEvent<Error>('ipns:routing:dht:error', err))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!putValue) {
|
|
44
|
+
throw new Error('Could not put value to DHT')
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async get (routingKey: Uint8Array, options: GetOptions = {}): Promise<Uint8Array> {
|
|
49
|
+
try {
|
|
50
|
+
for await (const event of this.dht.get(routingKey, options)) {
|
|
51
|
+
logEvent('DHT get event', event)
|
|
52
|
+
|
|
53
|
+
options.onProgress?.(new CustomProgressEvent<QueryEvent>('ipns:routing:dht:query', event))
|
|
54
|
+
|
|
55
|
+
if (event.name === 'VALUE') {
|
|
56
|
+
return event.value
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
options.onProgress?.(new CustomProgressEvent<Error>('ipns:routing:dht:error', err))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new Error('Not found')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function logEvent (prefix: string, event: QueryEvent): void {
|
|
68
|
+
if (event.name === 'SENDING_QUERY') {
|
|
69
|
+
log(prefix, event.name, event.messageName, '->', event.to.toString())
|
|
70
|
+
} else if (event.name === 'PEER_RESPONSE') {
|
|
71
|
+
log(prefix, event.name, event.messageName, '<-', event.from.toString())
|
|
72
|
+
} else if (event.name === 'FINAL_PEER') {
|
|
73
|
+
log(prefix, event.name, event.peer.id.toString())
|
|
74
|
+
} else if (event.name === 'QUERY_ERROR') {
|
|
75
|
+
log(prefix, event.name, event.error.message)
|
|
76
|
+
} else if (event.name === 'PROVIDER') {
|
|
77
|
+
log(prefix, event.name, event.providers.map(p => p.id.toString()).join(', '))
|
|
78
|
+
} else {
|
|
79
|
+
log(prefix, event.name)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function dht (components: DHTRoutingComponents): IPNSRouting {
|
|
84
|
+
return new DHTRouting(components)
|
|
85
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ProgressOptions } from 'progress-events'
|
|
2
|
+
import type { AbortOptions } from '@libp2p/interfaces'
|
|
3
|
+
import type { DHTProgressEvents } from './dht.js'
|
|
4
|
+
import type { DatastoreProgressEvents } from './local-store.js'
|
|
5
|
+
import type { PubSubProgressEvents } from './pubsub.js'
|
|
6
|
+
|
|
7
|
+
export interface PutOptions extends AbortOptions, ProgressOptions {
|
|
8
|
+
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface GetOptions extends AbortOptions, ProgressOptions {
|
|
12
|
+
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface IPNSRouting {
|
|
16
|
+
put: (routingKey: Uint8Array, marshaledRecord: Uint8Array, options?: PutOptions) => Promise<void>
|
|
17
|
+
get: (routingKey: Uint8Array, options?: GetOptions) => Promise<Uint8Array>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type IPNSRoutingEvents =
|
|
21
|
+
DatastoreProgressEvents |
|
|
22
|
+
DHTProgressEvents |
|
|
23
|
+
PubSubProgressEvents
|
|
24
|
+
|
|
25
|
+
export { dht } from './dht.js'
|
|
26
|
+
export { pubsub } from './pubsub.js'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { CustomProgressEvent, ProgressEvent } from 'progress-events'
|
|
2
|
+
import type { AbortOptions } from '@libp2p/interfaces'
|
|
3
|
+
import { Libp2pRecord } from '@libp2p/record'
|
|
4
|
+
import { Datastore, Key } from 'interface-datastore'
|
|
5
|
+
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
|
|
6
|
+
import type { GetOptions, IPNSRouting, PutOptions } from '../routing'
|
|
7
|
+
|
|
8
|
+
function dhtRoutingKey (key: Uint8Array): Key {
|
|
9
|
+
return new Key('/dht/record/' + uint8ArrayToString(key, 'base32'), false)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DatastoreProgressEvents =
|
|
13
|
+
ProgressEvent<'ipns:routing:datastore:put'> |
|
|
14
|
+
ProgressEvent<'ipns:routing:datastore:get'> |
|
|
15
|
+
ProgressEvent<'ipns:routing:datastore:error', Error>
|
|
16
|
+
|
|
17
|
+
export interface LocalStore extends IPNSRouting {
|
|
18
|
+
has: (routingKey: Uint8Array, options?: AbortOptions) => Promise<boolean>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns an IPNSRouting implementation that reads/writes IPNS records to the
|
|
23
|
+
* datastore as DHT records. This lets us publish IPNS records offline then
|
|
24
|
+
* serve them to the network later in response to DHT queries.
|
|
25
|
+
*/
|
|
26
|
+
export function localStore (datastore: Datastore): LocalStore {
|
|
27
|
+
return {
|
|
28
|
+
async put (routingKey: Uint8Array, marshaledRecord: Uint8Array, options: PutOptions = {}) {
|
|
29
|
+
try {
|
|
30
|
+
const key = dhtRoutingKey(routingKey)
|
|
31
|
+
|
|
32
|
+
// Marshal to libp2p record as the DHT does
|
|
33
|
+
const record = new Libp2pRecord(routingKey, marshaledRecord, new Date())
|
|
34
|
+
|
|
35
|
+
options.onProgress?.(new CustomProgressEvent('ipns:routing:datastore:put'))
|
|
36
|
+
await datastore.put(key, record.serialize(), options)
|
|
37
|
+
} catch (err: any) {
|
|
38
|
+
options.onProgress?.(new CustomProgressEvent<Error>('ipns:routing:datastore:error', err))
|
|
39
|
+
throw err
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
async get (routingKey: Uint8Array, options: GetOptions = {}): Promise<Uint8Array> {
|
|
43
|
+
try {
|
|
44
|
+
const key = dhtRoutingKey(routingKey)
|
|
45
|
+
|
|
46
|
+
options.onProgress?.(new CustomProgressEvent('ipns:routing:datastore:get'))
|
|
47
|
+
const buf = await datastore.get(key, options)
|
|
48
|
+
|
|
49
|
+
// Unmarshal libp2p record as the DHT does
|
|
50
|
+
const record = Libp2pRecord.deserialize(buf)
|
|
51
|
+
|
|
52
|
+
return record.value
|
|
53
|
+
} catch (err: any) {
|
|
54
|
+
options.onProgress?.(new CustomProgressEvent<Error>('ipns:routing:datastore:error', err))
|
|
55
|
+
throw err
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
async has (routingKey: Uint8Array, options: AbortOptions = {}): Promise<boolean> {
|
|
59
|
+
const key = dhtRoutingKey(routingKey)
|
|
60
|
+
return await datastore.has(key, options)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { peerIdToRoutingKey } from 'ipns'
|
|
2
|
+
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
|
|
3
|
+
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
|
|
4
|
+
import { logger } from '@libp2p/logger'
|
|
5
|
+
import type { PeerId } from '@libp2p/interface-peer-id'
|
|
6
|
+
import type { Message, PublishResult, PubSub } from '@libp2p/interface-pubsub'
|
|
7
|
+
import type { Datastore } from 'interface-datastore'
|
|
8
|
+
import type { GetOptions, IPNSRouting, PutOptions } from './index.js'
|
|
9
|
+
import { CodeError } from '@libp2p/interfaces/errors'
|
|
10
|
+
import { localStore, LocalStore } from './local-store.js'
|
|
11
|
+
import { ipnsValidator } from 'ipns/validator'
|
|
12
|
+
import { ipnsSelector } from 'ipns/selector'
|
|
13
|
+
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
|
|
14
|
+
import { CustomProgressEvent, ProgressEvent } from 'progress-events'
|
|
15
|
+
|
|
16
|
+
const log = logger('helia:ipns:routing:pubsub')
|
|
17
|
+
|
|
18
|
+
export interface PubsubRoutingComponents {
|
|
19
|
+
datastore: Datastore
|
|
20
|
+
libp2p: {
|
|
21
|
+
peerId: PeerId
|
|
22
|
+
pubsub: PubSub
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type PubSubProgressEvents =
|
|
27
|
+
ProgressEvent<'ipns:pubsub:publish', { topic: string, result: PublishResult }> |
|
|
28
|
+
ProgressEvent<'ipns:pubsub:subscribe', { topic: string }> |
|
|
29
|
+
ProgressEvent<'ipns:pubsub:error', Error>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* This IPNS routing receives IPNS record updates via dedicated
|
|
33
|
+
* pubsub topic.
|
|
34
|
+
*
|
|
35
|
+
* Note we must first be subscribed to the topic in order to receive
|
|
36
|
+
* updated records, so the first call to `.get` should be expected
|
|
37
|
+
* to fail!
|
|
38
|
+
*/
|
|
39
|
+
class PubSubRouting implements IPNSRouting {
|
|
40
|
+
private subscriptions: string[]
|
|
41
|
+
private readonly localStore: LocalStore
|
|
42
|
+
private readonly peerId: PeerId
|
|
43
|
+
private readonly pubsub: PubSub
|
|
44
|
+
|
|
45
|
+
constructor (components: PubsubRoutingComponents) {
|
|
46
|
+
this.subscriptions = []
|
|
47
|
+
this.localStore = localStore(components.datastore)
|
|
48
|
+
this.peerId = components.libp2p.peerId
|
|
49
|
+
this.pubsub = components.libp2p.pubsub
|
|
50
|
+
|
|
51
|
+
this.pubsub.addEventListener('message', (evt) => {
|
|
52
|
+
const message = evt.detail
|
|
53
|
+
|
|
54
|
+
if (!this.subscriptions.includes(message.topic)) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.#processPubSubMessage(message).catch(err => {
|
|
59
|
+
log.error('Error processing message', err)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async #processPubSubMessage (message: Message): Promise<void> {
|
|
65
|
+
log('message received for topic', message.topic)
|
|
66
|
+
|
|
67
|
+
if (message.type !== 'signed') {
|
|
68
|
+
log.error('unsigned message received, this module can only work with signed messages')
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (message.from.equals(this.peerId)) {
|
|
73
|
+
log('not storing record from self')
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const routingKey = topicToKey(message.topic)
|
|
78
|
+
|
|
79
|
+
await ipnsValidator(routingKey, message.data)
|
|
80
|
+
|
|
81
|
+
if (await this.localStore.has(routingKey)) {
|
|
82
|
+
const currentRecord = await this.localStore.get(routingKey)
|
|
83
|
+
|
|
84
|
+
if (uint8ArrayEquals(currentRecord, message.data)) {
|
|
85
|
+
log('not storing record as we already have it')
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const records = [currentRecord, message.data]
|
|
90
|
+
const index = ipnsSelector(routingKey, records)
|
|
91
|
+
|
|
92
|
+
if (index === 0) {
|
|
93
|
+
log('not storing record as the one we have is better')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await this.localStore.put(routingKey, message.data)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Put a value to the pubsub datastore indexed by the received key properly encoded
|
|
103
|
+
*/
|
|
104
|
+
async put (routingKey: Uint8Array, marshaledRecord: Uint8Array, options: PutOptions = {}): Promise<void> {
|
|
105
|
+
try {
|
|
106
|
+
const topic = keyToTopic(routingKey)
|
|
107
|
+
|
|
108
|
+
log('publish value for topic %s', topic)
|
|
109
|
+
const result = await this.pubsub.publish(topic, marshaledRecord)
|
|
110
|
+
|
|
111
|
+
log('published record on topic %s to %d recipients', topic, result.recipients)
|
|
112
|
+
options.onProgress?.(new CustomProgressEvent('ipns:pubsub:publish', { topic, result }))
|
|
113
|
+
} catch (err: any) {
|
|
114
|
+
options.onProgress?.(new CustomProgressEvent<Error>('ipns:pubsub:error', err))
|
|
115
|
+
throw err
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get a value from the pubsub datastore indexed by the received key properly encoded.
|
|
121
|
+
* Also, the identifier topic is subscribed to and the pubsub datastore records will be
|
|
122
|
+
* updated once new publishes occur
|
|
123
|
+
*/
|
|
124
|
+
async get (routingKey: Uint8Array, options: GetOptions = {}): Promise<Uint8Array> {
|
|
125
|
+
try {
|
|
126
|
+
const topic = keyToTopic(routingKey)
|
|
127
|
+
|
|
128
|
+
// ensure we are subscribed to topic
|
|
129
|
+
if (!this.pubsub.getTopics().includes(topic)) {
|
|
130
|
+
log('add subscription for topic', topic)
|
|
131
|
+
this.pubsub.subscribe(topic)
|
|
132
|
+
this.subscriptions.push(topic)
|
|
133
|
+
|
|
134
|
+
options.onProgress?.(new CustomProgressEvent('ipns:pubsub:subscribe', { topic }))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// chain through to local store
|
|
138
|
+
return await this.localStore.get(routingKey, options)
|
|
139
|
+
} catch (err: any) {
|
|
140
|
+
options.onProgress?.(new CustomProgressEvent<Error>('ipns:pubsub:error', err))
|
|
141
|
+
throw err
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get pubsub subscriptions related to ipns
|
|
147
|
+
*/
|
|
148
|
+
getSubscriptions (): string[] {
|
|
149
|
+
return this.subscriptions
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Cancel pubsub subscriptions related to ipns
|
|
154
|
+
*/
|
|
155
|
+
cancel (key: PeerId): void {
|
|
156
|
+
const routingKey = peerIdToRoutingKey(key)
|
|
157
|
+
const topic = keyToTopic(routingKey)
|
|
158
|
+
|
|
159
|
+
// Not found topic
|
|
160
|
+
if (!this.subscriptions.includes(topic)) {
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.pubsub.unsubscribe(topic)
|
|
165
|
+
this.subscriptions = this.subscriptions.filter(t => t !== topic)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const PUBSUB_NAMESPACE = '/record/'
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* converts a binary record key to a pubsub topic key
|
|
173
|
+
*/
|
|
174
|
+
function keyToTopic (key: Uint8Array): string {
|
|
175
|
+
const b64url = uint8ArrayToString(key, 'base64url')
|
|
176
|
+
|
|
177
|
+
return `${PUBSUB_NAMESPACE}${b64url}`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* converts a pubsub topic key to a binary record key
|
|
182
|
+
*/
|
|
183
|
+
function topicToKey (topic: string): Uint8Array {
|
|
184
|
+
if (topic.substring(0, PUBSUB_NAMESPACE.length) !== PUBSUB_NAMESPACE) {
|
|
185
|
+
throw new CodeError('topic received is not from a record', 'ERR_TOPIC_IS_NOT_FROM_RECORD_NAMESPACE')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const key = topic.substring(PUBSUB_NAMESPACE.length)
|
|
189
|
+
|
|
190
|
+
return uint8ArrayFromString(key, 'base64url')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function pubsub (components: PubsubRoutingComponents): IPNSRouting {
|
|
194
|
+
return new PubSubRouting(components)
|
|
195
|
+
}
|