@helia/dnslink 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/package.json ADDED
@@ -0,0 +1,98 @@
1
+ {
2
+ "name": "@helia/dnslink",
3
+ "version": "0.0.0",
4
+ "description": "DNSLink operations using Helia",
5
+ "license": "Apache-2.0 OR MIT",
6
+ "homepage": "https://github.com/ipfs/helia/tree/main/packages/dnslink#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ipfs/helia.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/ipfs/helia/issues"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "keywords": [
18
+ "IPFS"
19
+ ],
20
+ "type": "module",
21
+ "types": "./dist/src/index.d.ts",
22
+ "typesVersions": {
23
+ "*": {
24
+ "*": [
25
+ "*",
26
+ "dist/*",
27
+ "dist/src/*",
28
+ "dist/src/*/index"
29
+ ],
30
+ "src/*": [
31
+ "*",
32
+ "dist/*",
33
+ "dist/src/*",
34
+ "dist/src/*/index"
35
+ ]
36
+ }
37
+ },
38
+ "files": [
39
+ "src",
40
+ "dist",
41
+ "!dist/test",
42
+ "!**/*.tsbuildinfo"
43
+ ],
44
+ "exports": {
45
+ ".": {
46
+ "types": "./dist/src/index.d.ts",
47
+ "import": "./dist/src/index.js"
48
+ }
49
+ },
50
+ "scripts": {
51
+ "clean": "aegir clean",
52
+ "lint": "aegir lint",
53
+ "dep-check": "aegir dep-check",
54
+ "doc-check": "aegir doc-check",
55
+ "build": "aegir build",
56
+ "docs": "aegir docs",
57
+ "generate": "protons ./src/pb/metadata.proto",
58
+ "test": "aegir test",
59
+ "test:chrome": "aegir test -t browser --cov",
60
+ "test:chrome-webworker": "aegir test -t webworker",
61
+ "test:firefox": "aegir test -t browser -- --browser firefox",
62
+ "test:firefox-webworker": "aegir test -t webworker -- --browser firefox",
63
+ "test:node": "aegir test -t node --cov",
64
+ "test:electron-main": "aegir test -t electron-main"
65
+ },
66
+ "dependencies": {
67
+ "@libp2p/crypto": "^5.1.7",
68
+ "@helia/interface": "^5.4.0",
69
+ "@libp2p/interface": "^3.0.2",
70
+ "@libp2p/kad-dht": "^16.0.5",
71
+ "@libp2p/keychain": "^6.0.5",
72
+ "@libp2p/logger": "^6.0.5",
73
+ "@libp2p/peer-id": "^6.0.3",
74
+ "@libp2p/utils": "^7.0.5",
75
+ "@multiformats/dns": "^1.0.9",
76
+ "interface-datastore": "^9.0.2",
77
+ "ipns": "^10.1.2",
78
+ "multiformats": "^13.4.1",
79
+ "progress-events": "^1.0.1",
80
+ "protons-runtime": "^5.5.0",
81
+ "uint8arraylist": "^2.4.8",
82
+ "uint8arrays": "^5.1.0"
83
+ },
84
+ "devDependencies": {
85
+ "@libp2p/crypto": "^5.1.12",
86
+ "@types/dns-packet": "^5.6.5",
87
+ "aegir": "^47.0.22",
88
+ "datastore-core": "^11.0.2",
89
+ "it-drain": "^3.0.10",
90
+ "protons": "^7.6.1",
91
+ "sinon": "^21.0.0",
92
+ "sinon-ts": "^2.0.0"
93
+ },
94
+ "browser": {
95
+ "./dist/src/dns-resolvers/resolver.js": "./dist/src/dns-resolvers/resolver.browser.js"
96
+ },
97
+ "sideEffects": false
98
+ }
@@ -0,0 +1 @@
1
+ export const MAX_RECURSIVE_DEPTH = 32
package/src/dnslink.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { MAX_RECURSIVE_DEPTH, RecordType } from '@multiformats/dns'
2
+ import { DNSLinkNotFoundError } from './errors.js'
3
+ import { ipfs } from './namespaces/ipfs.ts'
4
+ import { ipns } from './namespaces/ipns.ts'
5
+ import type { DNSLink as DNSLinkInterface, ResolveDNSLinkOptions, DNSLinkOptions, DNSLinkComponents, DNSLinkResult, DNSLinkNamespace } from './index.js'
6
+ import type { Logger } from '@libp2p/interface'
7
+ import type { DNS } from '@multiformats/dns'
8
+
9
+ export class DNSLink implements DNSLinkInterface {
10
+ private readonly dns: DNS
11
+ private readonly log: Logger
12
+ private readonly namespaces: Record<string, DNSLinkNamespace>
13
+
14
+ constructor (components: DNSLinkComponents, init: DNSLinkOptions = {}) {
15
+ this.dns = components.dns
16
+ this.log = components.logger.forComponent('helia:dnslink')
17
+ this.namespaces = {
18
+ ipfs,
19
+ ipns,
20
+ ...init.namespaces
21
+ }
22
+ }
23
+
24
+ async resolve (domain: string, options: ResolveDNSLinkOptions = {}): Promise<DNSLinkResult> {
25
+ return this.recursiveResolveDomain(domain, options.maxRecursiveDepth ?? MAX_RECURSIVE_DEPTH, options)
26
+ }
27
+
28
+ async recursiveResolveDomain (domain: string, depth: number, options: ResolveDNSLinkOptions = {}): Promise<DNSLinkResult> {
29
+ if (depth === 0) {
30
+ throw new Error('recursion limit exceeded')
31
+ }
32
+
33
+ // the DNSLink spec says records MUST be stored on the `_dnslink.` subdomain
34
+ // so start looking for records there, we will fall back to the bare domain
35
+ // if none are found
36
+ if (!domain.startsWith('_dnslink.')) {
37
+ domain = `_dnslink.${domain}`
38
+ }
39
+
40
+ try {
41
+ return await this.recursiveResolveDnslink(domain, depth, options)
42
+ } catch (err: any) {
43
+ // If the code is not ENOTFOUND or ERR_DNSLINK_NOT_FOUND or ENODATA then throw the error
44
+ if (err.code !== 'ENOTFOUND' && err.code !== 'ENODATA' && err.name !== 'DNSLinkNotFoundError' && err.name !== 'NotFoundError') {
45
+ throw err
46
+ }
47
+
48
+ if (domain.startsWith('_dnslink.')) {
49
+ // The supplied domain contains a _dnslink component
50
+ // Check the non-_dnslink domain
51
+ domain = domain.replace('_dnslink.', '')
52
+ } else {
53
+ // Check the _dnslink subdomain
54
+ domain = `_dnslink.${domain}`
55
+ }
56
+
57
+ // If this throws then we propagate the error
58
+ return this.recursiveResolveDnslink(domain, depth, options)
59
+ }
60
+ }
61
+
62
+ async recursiveResolveDnslink (domain: string, depth: number, options: ResolveDNSLinkOptions = {}): Promise<DNSLinkResult> {
63
+ if (depth === 0) {
64
+ throw new Error('recursion limit exceeded')
65
+ }
66
+
67
+ this.log('query %s for TXT and CNAME records', domain)
68
+ const txtRecordsResponse = await this.dns.query(domain, {
69
+ ...options,
70
+ types: [
71
+ RecordType.TXT
72
+ ]
73
+ })
74
+
75
+ // sort the TXT records to ensure deterministic processing
76
+ const txtRecords = (txtRecordsResponse?.Answer ?? [])
77
+ .sort((a, b) => a.data.localeCompare(b.data))
78
+
79
+ this.log('found %d TXT records for %s', txtRecords.length, domain)
80
+
81
+ for (const answer of txtRecords) {
82
+ try {
83
+ let result = answer.data
84
+
85
+ // strip leading and trailing " characters
86
+ if (result.startsWith('"') && result.endsWith('"')) {
87
+ result = result.substring(1, result.length - 1)
88
+ }
89
+
90
+ if (!result.startsWith('dnslink=')) {
91
+ // invalid record?
92
+ continue
93
+ }
94
+
95
+ this.log('%s TXT %s', answer.name, result)
96
+
97
+ result = result.replace('dnslink=', '')
98
+
99
+ // result is now a `/ipfs/<cid>` or `/ipns/<cid>` string
100
+ const [, protocol, domainOrCID] = result.split('/') // e.g. ["", "ipfs", "<cid>"]
101
+
102
+ if (protocol === 'dnslink') {
103
+ // if the result was another DNSLink domain, try to follow it
104
+ return await this.recursiveResolveDomain(domainOrCID, depth - 1, options)
105
+ }
106
+
107
+ const parser = this.namespaces[protocol]
108
+
109
+ if (parser == null) {
110
+ this.log('unknown protocol "%s" in DNSLink record for domain: %s', protocol, domain)
111
+ continue
112
+ }
113
+
114
+ return parser.parse(result, answer)
115
+ } catch (err: any) {
116
+ this.log.error('could not parse DNS link record for domain %s, %s', domain, answer.data, err)
117
+ }
118
+ }
119
+
120
+ // no dnslink records found, try CNAMEs
121
+ this.log('no DNSLink records found for %s, falling back to CNAME', domain)
122
+
123
+ const cnameRecordsResponse = await this.dns.query(domain, {
124
+ ...options,
125
+ types: [
126
+ RecordType.CNAME
127
+ ]
128
+ })
129
+
130
+ // sort the CNAME records to ensure deterministic processing
131
+ const cnameRecords = (cnameRecordsResponse?.Answer ?? [])
132
+ .sort((a, b) => a.data.localeCompare(b.data))
133
+
134
+ this.log('found %d CNAME records for %s', cnameRecords.length, domain)
135
+
136
+ for (const cname of cnameRecords) {
137
+ try {
138
+ return await this.recursiveResolveDomain(cname.data, depth - 1, options)
139
+ } catch (err: any) {
140
+ this.log.error('domain %s cname %s had no DNSLink records - %e', domain, cname.data, err)
141
+ }
142
+ }
143
+
144
+ throw new DNSLinkNotFoundError(`No DNSLink records found for domain: ${domain}`)
145
+ }
146
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,17 @@
1
+ export class DNSLinkNotFoundError extends Error {
2
+ static name = 'DNSLinkNotFoundError'
3
+
4
+ constructor (message = 'DNSLink not found') {
5
+ super(message)
6
+ this.name = 'DNSLinkNotFoundError'
7
+ }
8
+ }
9
+
10
+ export class InvalidNamespaceError extends Error {
11
+ static name = 'InvalidNamespaceError'
12
+
13
+ constructor (message = 'Invalid namespace') {
14
+ super(message)
15
+ this.name = 'InvalidNamespaceError'
16
+ }
17
+ }
package/src/index.ts ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @packageDocumentation
3
+ *
4
+ * [DNSLink](https://dnslink.dev/) operations using a Helia node.
5
+ *
6
+ * @example Using custom DNS over HTTPS resolvers
7
+ *
8
+ * To use custom resolvers, configure Helia's `dns` option:
9
+ *
10
+ * ```TypeScript
11
+ * import { createHelia } from 'helia'
12
+ * import { dnsLink } from '@helia/dnslink'
13
+ * import { dns } from '@multiformats/dns'
14
+ * import { dnsOverHttps } from '@multiformats/dns/resolvers'
15
+ * import type { DefaultLibp2pServices } from 'helia'
16
+ * import type { Libp2p } from '@libp2p/interface'
17
+ *
18
+ * const node = await createHelia<Libp2p<DefaultLibp2pServices>>({
19
+ * dns: dns({
20
+ * resolvers: {
21
+ * '.': dnsOverHttps('https://private-dns-server.me/dns-query')
22
+ * }
23
+ * })
24
+ * })
25
+ * const name = dnsLink(node)
26
+ *
27
+ * const result = name.resolve('some-domain-with-dnslink-entry.com')
28
+ * ```
29
+ *
30
+ * @example Resolving a domain with a dnslink entry
31
+ *
32
+ * Calling `resolve` with the `@helia/dnslink` instance:
33
+ *
34
+ * ```TypeScript
35
+ * // resolve a CID from a TXT record in a DNS zone file, using the default
36
+ * // resolver for the current platform eg:
37
+ * // > dig _dnslink.ipfs.tech TXT
38
+ * // ;; ANSWER SECTION:
39
+ * // _dnslink.ipfs.tech. 60 IN CNAME _dnslink.ipfs-tech.on.fleek.co.
40
+ * // _dnslink.ipfs-tech.on.fleek.co. 120 IN TXT "dnslink=/ipfs/bafybe..."
41
+ *
42
+ * import { createHelia } from 'helia'
43
+ * import { dnsLink } from '@helia/dnslink'
44
+ *
45
+ * const node = await createHelia()
46
+ * const name = dnsLink(node)
47
+ *
48
+ * const { answer } = await name.resolve('blog.ipfs.tech')
49
+ *
50
+ * console.info(answer)
51
+ * // { data: '/ipfs/bafybe...' }
52
+ * ```
53
+ *
54
+ * @example Using DNS-Over-HTTPS
55
+ *
56
+ * This example uses the Mozilla provided RFC 1035 DNS over HTTPS service. This
57
+ * uses binary DNS records so requires extra dependencies to process the
58
+ * response which can increase browser bundle sizes.
59
+ *
60
+ * If this is a concern, use the DNS-JSON-Over-HTTPS resolver instead.
61
+ *
62
+ * ```TypeScript
63
+ * import { createHelia } from 'helia'
64
+ * import { dnsLink } from '@helia/dnslink'
65
+ * import { dns } from '@multiformats/dns'
66
+ * import { dnsOverHttps } from '@multiformats/dns/resolvers'
67
+ * import type { DefaultLibp2pServices } from 'helia'
68
+ * import type { Libp2p } from '@libp2p/interface'
69
+ *
70
+ * const node = await createHelia<Libp2p<DefaultLibp2pServices>>({
71
+ * dns: dns({
72
+ * resolvers: {
73
+ * '.': dnsOverHttps('https://mozilla.cloudflare-dns.com/dns-query')
74
+ * }
75
+ * })
76
+ * })
77
+ * const name = dnsLink(node)
78
+ *
79
+ * const result = await name.resolve('blog.ipfs.tech')
80
+ * ```
81
+ *
82
+ * @example Using DNS-JSON-Over-HTTPS
83
+ *
84
+ * DNS-JSON-Over-HTTPS resolvers use the RFC 8427 `application/dns-json` and can
85
+ * result in a smaller browser bundle due to the response being plain JSON.
86
+ *
87
+ * ```TypeScript
88
+ * import { createHelia } from 'helia'
89
+ * import { dnsLink } from '@helia/dnslink'
90
+ * import { dns } from '@multiformats/dns'
91
+ * import { dnsJsonOverHttps } from '@multiformats/dns/resolvers'
92
+ * import type { DefaultLibp2pServices } from 'helia'
93
+ * import type { Libp2p } from '@libp2p/interface'
94
+ *
95
+ * const node = await createHelia<Libp2p<DefaultLibp2pServices>>({
96
+ * dns: dns({
97
+ * resolvers: {
98
+ * '.': dnsJsonOverHttps('https://mozilla.cloudflare-dns.com/dns-query')
99
+ * }
100
+ * })
101
+ * })
102
+ * const name = dnsLink(node)
103
+ *
104
+ * const result = await name.resolve('blog.ipfs.tech')
105
+ * ```
106
+ */
107
+
108
+ import { DNSLink as DNSLinkClass } from './dnslink.js'
109
+ import type { AbortOptions, ComponentLogger, PeerId } from '@libp2p/interface'
110
+ import type { Answer, DNS, ResolveDnsProgressEvents } from '@multiformats/dns'
111
+ import type { CID } from 'multiformats/cid'
112
+ import type { ProgressOptions } from 'progress-events'
113
+
114
+ export interface ResolveDNSLinkOptions extends AbortOptions, ProgressOptions<ResolveDnsProgressEvents> {
115
+ /**
116
+ * Do not query the network for the IPNS record
117
+ *
118
+ * @default false
119
+ */
120
+ offline?: boolean
121
+
122
+ /**
123
+ * Do not use cached DNS entries
124
+ *
125
+ * @default false
126
+ */
127
+ nocache?: boolean
128
+
129
+ /**
130
+ * When resolving DNSLink records that resolve to other DNSLink records, limit
131
+ * how many times we will recursively resolve them.
132
+ *
133
+ * @default 32
134
+ */
135
+ maxRecursiveDepth?: number
136
+ }
137
+
138
+ export interface DNSLinkIPFSResult {
139
+ /**
140
+ * The resolved record
141
+ */
142
+ answer: Answer
143
+
144
+ /**
145
+ * The IPFS namespace
146
+ */
147
+ namespace: 'ipfs'
148
+
149
+ /**
150
+ * The resolved value
151
+ */
152
+ cid: CID
153
+
154
+ /**
155
+ * If the resolved value is an IPFS path, it will be present here
156
+ */
157
+ path: string
158
+ }
159
+
160
+ export interface DNSLinkIPNSResult {
161
+ /**
162
+ * The resolved record
163
+ */
164
+ answer: Answer
165
+
166
+ /**
167
+ * The IPFS namespace
168
+ */
169
+ namespace: 'ipns'
170
+
171
+ /**
172
+ * The resolved value
173
+ */
174
+ peerId: PeerId
175
+
176
+ /**
177
+ * If the resolved value is an IPFS path, it will be present here
178
+ */
179
+ path: string
180
+ }
181
+
182
+ export interface DNSLinkOtherResult {
183
+ /**
184
+ * The resolved record
185
+ */
186
+ answer: Answer
187
+
188
+ /**
189
+ * The IPFS namespace
190
+ */
191
+ namespace: string
192
+ }
193
+
194
+ export type DNSLinkResult = DNSLinkIPFSResult | DNSLinkIPNSResult | DNSLinkOtherResult
195
+
196
+ export interface DNSLinkNamespace {
197
+ /**
198
+ * Return a result parsed from a DNSLink value
199
+ */
200
+ parse(value: string, answer: Answer): DNSLinkResult
201
+ }
202
+
203
+ export interface DNSLink {
204
+ /**
205
+ * Resolve a CID from a dns-link style IPNS record
206
+ *
207
+ * @example
208
+ *
209
+ * ```TypeScript
210
+ * import { createHelia } from 'helia'
211
+ * import { dnsLink } from '@helia/dnslink'
212
+ *
213
+ * const helia = await createHelia()
214
+ * const name = dnsLink(helia)
215
+ *
216
+ * const result = await name.resolve('ipfs.io', {
217
+ * signal: AbortSignal.timeout(5_000)
218
+ * })
219
+ *
220
+ * console.info(result) // { answer: ..., value: ... }
221
+ * ```
222
+ */
223
+ resolve(domain: string, options?: ResolveDNSLinkOptions): Promise<DNSLinkResult>
224
+ }
225
+
226
+ export interface DNSLinkComponents {
227
+ dns: DNS
228
+ logger: ComponentLogger
229
+ }
230
+
231
+ export interface DNSLinkOptions {
232
+ /**
233
+ * By default `/ipfs/...`, `/ipns/...` and `/dnslink/...` record values are
234
+ * supported - to support other prefixes pass other value parsers here
235
+ */
236
+ namespaces?: Record<string, DNSLinkNamespace>
237
+ }
238
+
239
+ export function dnsLink (components: DNSLinkComponents, options: DNSLinkOptions = {}): DNSLink {
240
+ return new DNSLinkClass(components, options)
241
+ }
@@ -0,0 +1,22 @@
1
+ import { CID } from 'multiformats/cid'
2
+ import { InvalidNamespaceError } from '../errors.ts'
3
+ import type { DNSLinkResult, DNSLinkNamespace } from '../index.js'
4
+ import type { Answer } from '@multiformats/dns'
5
+
6
+ export const ipfs: DNSLinkNamespace = {
7
+ parse: (value: string, answer: Answer): DNSLinkResult => {
8
+ const [, protocol, cid, ...rest] = value.split('/')
9
+
10
+ if (protocol !== 'ipfs') {
11
+ throw new InvalidNamespaceError(`Namespace ${protocol} was not "ipfs"`)
12
+ }
13
+
14
+ // if the result is a CID, we've reached the end of the recursion
15
+ return {
16
+ namespace: 'ipfs',
17
+ cid: CID.parse(cid),
18
+ path: rest.length > 0 ? `/${rest.join('/')}` : '',
19
+ answer
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,22 @@
1
+ import { peerIdFromString } from '@libp2p/peer-id'
2
+ import { InvalidNamespaceError } from '../errors.ts'
3
+ import type { DNSLinkResult, DNSLinkNamespace } from '../index.js'
4
+ import type { Answer } from '@multiformats/dns'
5
+
6
+ export const ipns: DNSLinkNamespace = {
7
+ parse: (value: string, answer: Answer): DNSLinkResult => {
8
+ const [, protocol, peerId, ...rest] = value.split('/')
9
+
10
+ if (protocol !== 'ipns') {
11
+ throw new InvalidNamespaceError(`Namespace ${protocol} was not "ipns"`)
12
+ }
13
+
14
+ // if the result is a CID, we've reached the end of the recursion
15
+ return {
16
+ namespace: 'ipns',
17
+ peerId: peerIdFromString(peerId),
18
+ path: rest.length > 0 ? `/${rest.join('/')}` : '',
19
+ answer
20
+ }
21
+ }
22
+ }