@helia/verified-fetch 4.0.1 → 4.0.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.
Files changed (68) hide show
  1. package/dist/index.min.js +41 -41
  2. package/dist/index.min.js.map +4 -4
  3. package/dist/src/index.d.ts +4 -2
  4. package/dist/src/index.d.ts.map +1 -1
  5. package/dist/src/index.js.map +1 -1
  6. package/dist/src/plugins/plugin-handle-dag-cbor-html-preview.d.ts +3 -3
  7. package/dist/src/plugins/plugin-handle-dag-cbor-html-preview.d.ts.map +1 -1
  8. package/dist/src/plugins/plugin-handle-dag-cbor-html-preview.js +5 -5
  9. package/dist/src/plugins/plugin-handle-dag-cbor-html-preview.js.map +1 -1
  10. package/dist/src/plugins/plugin-handle-dag-pb.d.ts.map +1 -1
  11. package/dist/src/plugins/plugin-handle-dag-pb.js +22 -24
  12. package/dist/src/plugins/plugin-handle-dag-pb.js.map +1 -1
  13. package/dist/src/plugins/plugin-handle-ipns-record.d.ts +1 -1
  14. package/dist/src/plugins/plugin-handle-ipns-record.d.ts.map +1 -1
  15. package/dist/src/plugins/plugin-handle-ipns-record.js +2 -2
  16. package/dist/src/plugins/plugin-handle-ipns-record.js.map +1 -1
  17. package/dist/src/plugins/plugin-handle-raw.js +1 -1
  18. package/dist/src/plugins/plugin-handle-raw.js.map +1 -1
  19. package/dist/src/plugins/types.d.ts +1 -4
  20. package/dist/src/plugins/types.d.ts.map +1 -1
  21. package/dist/src/url-resolver.d.ts +4 -3
  22. package/dist/src/url-resolver.d.ts.map +1 -1
  23. package/dist/src/url-resolver.js +35 -47
  24. package/dist/src/url-resolver.js.map +1 -1
  25. package/dist/src/utils/dnslink-label.d.ts +26 -0
  26. package/dist/src/utils/dnslink-label.d.ts.map +1 -0
  27. package/dist/src/utils/dnslink-label.js +35 -0
  28. package/dist/src/utils/dnslink-label.js.map +1 -0
  29. package/dist/src/utils/get-content-type.d.ts +1 -1
  30. package/dist/src/utils/get-content-type.d.ts.map +1 -1
  31. package/dist/src/utils/get-content-type.js +1 -1
  32. package/dist/src/utils/get-content-type.js.map +1 -1
  33. package/dist/src/utils/get-stream-from-async-iterable.d.ts +1 -2
  34. package/dist/src/utils/get-stream-from-async-iterable.d.ts.map +1 -1
  35. package/dist/src/utils/get-stream-from-async-iterable.js +1 -3
  36. package/dist/src/utils/get-stream-from-async-iterable.js.map +1 -1
  37. package/dist/src/utils/handle-redirects.js +2 -2
  38. package/dist/src/utils/ipfs-path-to-url.d.ts +16 -0
  39. package/dist/src/utils/ipfs-path-to-url.d.ts.map +1 -0
  40. package/dist/src/utils/ipfs-path-to-url.js +45 -0
  41. package/dist/src/utils/ipfs-path-to-url.js.map +1 -0
  42. package/dist/src/utils/parse-url-string.d.ts +18 -5
  43. package/dist/src/utils/parse-url-string.d.ts.map +1 -1
  44. package/dist/src/utils/parse-url-string.js +126 -44
  45. package/dist/src/utils/parse-url-string.js.map +1 -1
  46. package/dist/src/utils/resource-to-cache-key.js +2 -2
  47. package/dist/src/utils/responses.d.ts.map +1 -1
  48. package/dist/src/utils/responses.js +4 -0
  49. package/dist/src/utils/responses.js.map +1 -1
  50. package/dist/src/utils/walk-path.js +1 -1
  51. package/dist/src/utils/walk-path.js.map +1 -1
  52. package/package.json +10 -10
  53. package/src/index.ts +4 -2
  54. package/src/plugins/plugin-handle-dag-cbor-html-preview.ts +8 -8
  55. package/src/plugins/plugin-handle-dag-pb.ts +27 -23
  56. package/src/plugins/plugin-handle-ipns-record.ts +2 -2
  57. package/src/plugins/plugin-handle-raw.ts +1 -1
  58. package/src/plugins/types.ts +1 -4
  59. package/src/url-resolver.ts +37 -56
  60. package/src/utils/dnslink-label.ts +38 -0
  61. package/src/utils/get-content-type.ts +2 -2
  62. package/src/utils/get-stream-from-async-iterable.ts +1 -4
  63. package/src/utils/handle-redirects.ts +2 -2
  64. package/src/utils/ipfs-path-to-url.ts +54 -0
  65. package/src/utils/parse-url-string.ts +166 -49
  66. package/src/utils/resource-to-cache-key.ts +2 -2
  67. package/src/utils/responses.ts +6 -0
  68. package/src/utils/walk-path.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helia/verified-fetch",
3
- "version": "4.0.1",
3
+ "version": "4.0.2",
4
4
  "description": "A fetch-like API for obtaining verified & trustless IPFS content on the web",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "homepage": "https://github.com/ipfs/helia-verified-fetch/tree/main/packages/verified-fetch#readme",
@@ -175,12 +175,12 @@
175
175
  "@ipld/dag-cbor": "^9.2.3",
176
176
  "@ipld/dag-json": "^10.2.4",
177
177
  "@ipld/dag-pb": "^4.1.5",
178
- "@libp2p/interface": "^3.0.0",
179
- "@libp2p/kad-dht": "^16.0.0",
180
- "@libp2p/peer-id": "^6.0.0",
181
- "@libp2p/utils": "^7.0.5",
182
- "@libp2p/webrtc": "^6.0.0",
183
- "@libp2p/websockets": "^10.0.0",
178
+ "@libp2p/interface": "^3.1.0",
179
+ "@libp2p/kad-dht": "^16.1.0",
180
+ "@libp2p/peer-id": "^6.0.4",
181
+ "@libp2p/utils": "^7.0.7",
182
+ "@libp2p/webrtc": "^6.0.8",
183
+ "@libp2p/websockets": "^10.1.0",
184
184
  "@multiformats/dns": "^1.0.6",
185
185
  "cborg": "^4.2.11",
186
186
  "file-type": "^21.0.0",
@@ -193,7 +193,7 @@
193
193
  "it-tar": "^6.0.5",
194
194
  "it-to-browser-readablestream": "^2.0.11",
195
195
  "it-to-buffer": "^4.0.9",
196
- "libp2p": "^3.0.0",
196
+ "libp2p": "^3.1.0",
197
197
  "multiformats": "^13.3.6",
198
198
  "progress-events": "^1.0.1",
199
199
  "quick-lru": "^7.0.1"
@@ -204,8 +204,8 @@
204
204
  "@helia/http": "^3.0.5",
205
205
  "@helia/json": "^5.0.0",
206
206
  "@ipld/car": "^5.4.2",
207
- "@libp2p/crypto": "^5.1.3",
208
- "@libp2p/logger": "^6.0.0",
207
+ "@libp2p/crypto": "^5.1.13",
208
+ "@libp2p/logger": "^6.2.0",
209
209
  "@types/sinon": "^17.0.4",
210
210
  "aegir": "^47.0.24",
211
211
  "browser-readablestream-to-it": "^2.0.9",
package/src/index.ts CHANGED
@@ -856,7 +856,7 @@ export interface ResourceDetail {
856
856
 
857
857
  export interface CIDDetail {
858
858
  cid: CID
859
- path?: string
859
+ path?: string[]
860
860
  }
861
861
 
862
862
  export interface CIDDetailError extends CIDDetail {
@@ -1069,10 +1069,12 @@ export interface UrlQuery extends Record<string, string | unknown> {
1069
1069
  }
1070
1070
 
1071
1071
  export interface ResolveURLResult {
1072
+ url: URL
1072
1073
  cid: CID
1073
1074
  protocol: string
1074
1075
  ttl: number
1075
- path: string
1076
+ path: string[]
1077
+ fragment: string
1076
1078
  query: UrlQuery
1077
1079
  ipfsPath: string
1078
1080
  }
@@ -98,7 +98,7 @@ export class DagCborHtmlPreviewPlugin extends BasePlugin {
98
98
  })
99
99
  }
100
100
 
101
- getHtml ({ path, obj, cid }: { path?: string, obj: Record<string, any>, cid: CID }): string {
101
+ getHtml ({ path, obj, cid }: { path?: string[], obj: Record<string, any>, cid: CID }): string {
102
102
  const style = `
103
103
  :root {
104
104
  --sans-serif: "Plex", system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
@@ -221,7 +221,7 @@ export class DagCborHtmlPreviewPlugin extends BasePlugin {
221
221
  <main>
222
222
  <header>
223
223
  <div><strong>CID: </strong> <code class="nowrap">${cid}</code></div>
224
- <div><strong>Codec: </strong> ${this.valueHTML('dag-cbor (0x71)', null)}</div>
224
+ <div><strong>Codec: </strong> ${this.valueHTML('dag-cbor (0x71)', [])}</div>
225
225
  </header>
226
226
  <section class="container">
227
227
  <p>You can download this block as:</p>
@@ -242,7 +242,7 @@ export class DagCborHtmlPreviewPlugin extends BasePlugin {
242
242
  </html>`
243
243
  }
244
244
 
245
- valueHTML (value: any, link: string | null): string {
245
+ valueHTML (value: any, link: string[]): string {
246
246
  let valueString: string
247
247
  const isALinkObject = isLink(value)
248
248
  if (!isALinkObject && typeof value !== 'string') {
@@ -259,11 +259,11 @@ export class DagCborHtmlPreviewPlugin extends BasePlugin {
259
259
  return valueCodeBlock
260
260
  }
261
261
 
262
- private renderValue (key: string, value: any, currentPath: string): string {
262
+ private renderValue (key: string, value: any, currentPath: string[]): string {
263
263
  let rows = ''
264
264
  value.forEach((item: any, idx: number) => {
265
- const itemPath = currentPath ? `${currentPath}/${key}/${idx}` : `${key}/${idx}`
266
- rows += `<div>${this.valueHTML(idx, null)}</div>`
265
+ const itemPath = [...currentPath, key, idx.toString()]
266
+ rows += `<div>${this.valueHTML(idx, [])}</div>`
267
267
  if (isPrimitive(item)) {
268
268
  rows += `<div>${this.valueHTML(item, itemPath)}</div>`
269
269
  } else {
@@ -275,7 +275,7 @@ export class DagCborHtmlPreviewPlugin extends BasePlugin {
275
275
  return rows
276
276
  }
277
277
 
278
- renderRows (obj: Record<string, any>, currentPath: string = ''): string {
278
+ renderRows (obj: Record<string, any>, currentPath: string[] = []): string {
279
279
  let rows = ''
280
280
  for (const [key, value] of Object.entries(obj)) {
281
281
  if (Array.isArray(value)) {
@@ -284,7 +284,7 @@ export class DagCborHtmlPreviewPlugin extends BasePlugin {
284
284
  rows += this.renderValue(key, value, currentPath)
285
285
  rows += '</div>'
286
286
  } else {
287
- const valuePath = currentPath ? `${currentPath}/${key}` : key
287
+ const valuePath = [...currentPath, key]
288
288
  rows += `<div>${key}</div><div>${this.valueHTML(value, valuePath)}</div>`
289
289
  }
290
290
  }
@@ -40,23 +40,25 @@ export class DagPbPlugin extends BasePlugin {
40
40
  * @see https://specs.ipfs.tech/http-gateways/path-gateway/#use-in-directory-url-normalization
41
41
  */
42
42
  getRedirectUrl (context: PluginContext): string | null {
43
- const { resource, path } = context
44
- const redirectCheckNeeded = path === '' ? !resource.toString().endsWith('/') : !path.endsWith('/')
45
- if (redirectCheckNeeded) {
46
- try {
47
- const url = new URL(resource.toString())
48
- if (url.pathname.endsWith('/')) {
49
- // url already has a trailing slash
50
- return null
51
- }
52
- // make sure we append slash to end of the path
53
- url.pathname = `${url.pathname}/`
54
- return url.toString()
55
- } catch (err: any) {
56
- // resource is likely a CID
57
- return `${resource.toString()}/`
58
- }
43
+ const { resource, url, isDirectory } = context
44
+
45
+ let uri: URL
46
+
47
+ try {
48
+ // try the requested resource
49
+ uri = new URL(resource)
50
+ } catch {
51
+ // fall back to the canonical URL
52
+ uri = url
59
53
  }
54
+
55
+ // directories must be requested with a trailing slash
56
+ if (isDirectory && !uri.pathname.endsWith('/')) {
57
+ // make sure we append slash to end of the path
58
+ uri.pathname += '/'
59
+ return uri.toString()
60
+ }
61
+
60
62
  return null
61
63
  }
62
64
 
@@ -65,7 +67,7 @@ export class DagPbPlugin extends BasePlugin {
65
67
  const { contentTypeParser, helia, getBlockstore } = this.pluginOptions
66
68
  const log = this.log
67
69
  let resource = context.resource
68
- let path = context.path
70
+ const path = context.path
69
71
 
70
72
  let redirected = false
71
73
 
@@ -75,8 +77,9 @@ export class DagPbPlugin extends BasePlugin {
75
77
  let resolvedCID = terminalElement.cid
76
78
  const fs = unixfs({ ...helia, blockstore: getBlockstore(context.cid, context.resource, options?.session ?? true, options) })
77
79
 
80
+ context.isDirectory = terminalElement?.type === 'directory'
81
+
78
82
  if (terminalElement?.type === 'directory') {
79
- const dirCid = terminalElement.cid
80
83
  const redirectUrl = this.getRedirectUrl(context)
81
84
 
82
85
  if (redirectUrl != null) {
@@ -95,7 +98,9 @@ export class DagPbPlugin extends BasePlugin {
95
98
  redirected = true
96
99
  }
97
100
 
101
+ const dirCid = terminalElement.cid
98
102
  const rootFilePath = 'index.html'
103
+
99
104
  try {
100
105
  log.trace('found directory at %c/%s, looking for index.html', cid, path)
101
106
 
@@ -105,7 +110,6 @@ export class DagPbPlugin extends BasePlugin {
105
110
  }))
106
111
 
107
112
  log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, entry.cid)
108
- path = rootFilePath
109
113
  resolvedCID = entry.cid
110
114
  } catch (err: any) {
111
115
  if (options?.signal?.aborted) {
@@ -126,7 +130,7 @@ export class DagPbPlugin extends BasePlugin {
126
130
  // dir-index-html plugin or dir-index-json (future idea?) plugin should handle this
127
131
  return null
128
132
  } finally {
129
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: dirCid, path: rootFilePath }))
133
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: dirCid, path: [rootFilePath] }))
130
134
  }
131
135
  }
132
136
 
@@ -157,13 +161,13 @@ export class DagPbPlugin extends BasePlugin {
157
161
  })
158
162
  log('got async iterator for %c/%s', cid, path)
159
163
 
160
- const streamAndFirstChunk = await context.serverTiming.time('stream-and-chunk', '', getStreamFromAsyncIterable(asyncIter, path, this.pluginOptions.logger, {
164
+ const streamAndFirstChunk = await context.serverTiming.time('stream-and-chunk', '', getStreamFromAsyncIterable(asyncIter, {
161
165
  onProgress: options?.onProgress,
162
166
  signal: options?.signal
163
167
  }))
164
168
  const stream = streamAndFirstChunk.stream
165
169
  firstChunk = streamAndFirstChunk.firstChunk
166
- contentType = await context.serverTiming.time('get-content-type', '', getContentType({ filename: query.filename, bytes: firstChunk, path, contentTypeParser, log }))
170
+ contentType = await context.serverTiming.time('get-content-type', '', getContentType({ path, filename: query.filename, bytes: firstChunk, contentTypeParser, log }))
167
171
 
168
172
  byteRangeContext.setBody(stream)
169
173
  }
@@ -207,7 +211,7 @@ export class DagPbPlugin extends BasePlugin {
207
211
  length: 8192
208
212
  })
209
213
 
210
- const { firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.pluginOptions.logger, {
214
+ const { firstChunk } = await getStreamFromAsyncIterable(asyncIter, {
211
215
  onProgress: options?.onProgress,
212
216
  signal: options?.signal
213
217
  })
@@ -13,7 +13,7 @@ export class IpnsRecordPlugin extends BasePlugin {
13
13
  readonly id = 'ipns-record-plugin'
14
14
  readonly codes = []
15
15
 
16
- canHandle ({ resource, accept, query, path, byteRangeContext }: PluginContext): boolean {
16
+ canHandle ({ accept, query, byteRangeContext }: PluginContext): boolean {
17
17
  if (byteRangeContext == null) {
18
18
  return false
19
19
  }
@@ -26,7 +26,7 @@ export class IpnsRecordPlugin extends BasePlugin {
26
26
  const { ipnsResolver } = this.pluginOptions
27
27
  context.reqFormat = 'ipns-record'
28
28
 
29
- if (path !== '' || !(resource.startsWith('ipns://') || resource.includes('.ipns.') || resource.includes('/ipns/'))) {
29
+ if (path.length > 0 || !(resource.startsWith('ipns://') || resource.includes('.ipns.') || resource.includes('/ipns/'))) {
30
30
  this.log.error('invalid request for IPNS name "%s" and path "%s"', resource, path)
31
31
  return badRequestResponse(resource, new Error('Invalid IPNS name'))
32
32
  }
@@ -70,7 +70,7 @@ export class RawPlugin extends BasePlugin {
70
70
  log.trace('did not set content disposition, raw block will display inline')
71
71
  }
72
72
 
73
- if (path !== '' && cid.code === rawCode) {
73
+ if (path.length > 0 && cid.code === rawCode) {
74
74
  log.trace('404-ing raw codec request for %c/%s', cid, path)
75
75
  return notFoundResponse(resource)
76
76
  }
@@ -1,4 +1,4 @@
1
- import type { ResolveURLResult, UrlQuery, VerifiedFetchInit, ContentTypeParser, RequestFormatShorthand } from '../index.js'
1
+ import type { ResolveURLResult, VerifiedFetchInit, ContentTypeParser, RequestFormatShorthand } from '../index.js'
2
2
  import type { ByteRangeContext } from '../utils/byte-range-context.js'
3
3
  import type { AcceptHeader } from '../utils/select-output-type.ts'
4
4
  import type { ServerTiming } from '../utils/server-timing.ts'
@@ -31,8 +31,6 @@ export interface PluginOptions {
31
31
  * - Ephemeral: Typically discarded once fetch(...) completes.
32
32
  */
33
33
  export interface PluginContext extends ResolveURLResult {
34
- readonly cid: CID
35
- readonly path: string
36
34
  readonly resource: string
37
35
  readonly accept?: AcceptHeader
38
36
 
@@ -52,7 +50,6 @@ export interface PluginContext extends ResolveURLResult {
52
50
  directoryEntries?: UnixFSEntry[]
53
51
  reqFormat?: RequestFormatShorthand
54
52
  pathDetails?: PathWalkerResponse
55
- query: UrlQuery
56
53
 
57
54
  /**
58
55
  * ByteRangeContext contains information about the size of the content and range requests.
@@ -1,7 +1,8 @@
1
1
  import { peerIdFromCID, peerIdFromString } from '@libp2p/peer-id'
2
2
  import { CID } from 'multiformats/cid'
3
- import { matchURLString } from './utils/parse-url-string.ts'
3
+ import { parseURLString } from './utils/parse-url-string.ts'
4
4
  import type { ResolveURLOptions, ResolveURLResult, Resource, URLResolver as URLResolverInterface } from './index.ts'
5
+ import type { ParsedURL } from './utils/parse-url-string.ts'
5
6
  import type { ServerTiming } from './utils/server-timing.ts'
6
7
  import type { DNSLink } from '@helia/dnslink'
7
8
  import type { IPNSResolver } from '@helia/ipns'
@@ -15,29 +16,6 @@ export interface URLResolverComponents {
15
16
  timing: ServerTiming
16
17
  }
17
18
 
18
- function toQuery (query?: string): Record<string, any> {
19
- if (query == null) {
20
- return {}
21
- }
22
-
23
- const params = new URLSearchParams(query)
24
- const output: Record<string, any> = {}
25
-
26
- for (const [key, value] of params.entries()) {
27
- output[key] = value
28
-
29
- if (value === 'true') {
30
- output[key] = true
31
- }
32
-
33
- if (value === 'false') {
34
- output[key] = false
35
- }
36
- }
37
-
38
- return output
39
- }
40
-
41
19
  export class URLResolver implements URLResolverInterface {
42
20
  private readonly components: URLResolverComponents
43
21
 
@@ -53,56 +31,60 @@ export class URLResolver implements URLResolverInterface {
53
31
  const cid = CID.asCID(resource)
54
32
 
55
33
  if (cid != null) {
56
- return this.resolveCIDResource(cid, '', {}, options)
34
+ return this.resolveCIDResource(cid, {
35
+ url: new URL(`ipfs://${cid}`)
36
+ }, options)
57
37
  }
58
38
 
59
39
  throw new TypeError(`Invalid resource. Cannot determine CID from resource: ${resource}`)
60
40
  }
61
41
 
62
42
  async parseUrlString (urlString: string, options: ResolveURLOptions = {}): Promise<ResolveURLResult> {
63
- const { protocol, cidOrPeerIdOrDnsLink, path, query } = matchURLString(urlString)
43
+ const result = parseURLString(urlString)
64
44
 
65
- if (protocol === 'ipfs') {
66
- const cid = CID.parse(cidOrPeerIdOrDnsLink)
45
+ if (result.protocol === 'ipfs') {
46
+ const cid = CID.parse(result.cidOrPeerIdOrDnsLink)
67
47
 
68
- return this.resolveCIDResource(cid, path ?? '', toQuery(query), options)
48
+ return this.resolveCIDResource(cid, result, options)
69
49
  }
70
50
 
71
- if (protocol === 'ipns') {
51
+ if (result.protocol === 'ipns') {
72
52
  // try to parse target as peer id
73
53
  let peerId: PeerId
74
54
 
75
55
  try {
76
- peerId = peerIdFromString(cidOrPeerIdOrDnsLink)
56
+ peerId = peerIdFromString(result.cidOrPeerIdOrDnsLink)
77
57
  } catch {
78
58
  // fall back to DNSLink (e.g. /ipns/example.com)
79
- return this.resolveDNSLink(cidOrPeerIdOrDnsLink, path ?? '', toQuery(query), options)
59
+ return this.resolveDNSLink(result.cidOrPeerIdOrDnsLink, result, options)
80
60
  }
81
61
 
82
62
  // parse multihash from string (e.g. /ipns/QmFoo...)
83
- return this.resolveIPNSName(cidOrPeerIdOrDnsLink, peerId, path ?? '', toQuery(query), options)
63
+ return this.resolveIPNSName(result.cidOrPeerIdOrDnsLink, peerId, result, options)
84
64
  }
85
65
 
86
66
  throw new TypeError(`Invalid resource. Cannot determine CID from resource: ${urlString}`)
87
67
  }
88
68
 
89
- async resolveCIDResource (cid: CID, path: string, query: Record<string, any>, options: ResolveURLOptions = {}): Promise<ResolveURLResult> {
69
+ async resolveCIDResource (cid: CID, parsed: Partial<ParsedURL> & Pick<ParsedURL, 'url'>, options: ResolveURLOptions = {}): Promise<ResolveURLResult> {
90
70
  if (cid.code === CODEC_LIBP2P_KEY) {
91
71
  // special case - peer id encoded as a CID
92
- return this.resolveIPNSName(cid.toString(), peerIdFromCID(cid), path, query, options)
72
+ return this.resolveIPNSName(cid.toString(), peerIdFromCID(cid), parsed, options)
93
73
  }
94
74
 
95
75
  return {
76
+ url: parsed.url,
96
77
  cid,
97
78
  protocol: 'ipfs',
98
- query,
99
- path,
79
+ query: parsed.query ?? {},
80
+ path: parsed.path ?? [],
81
+ fragment: parsed.fragment ?? '',
100
82
  ttl: 29030400, // 1 year for ipfs content
101
- ipfsPath: `/ipfs/${cid}${path === '' ? '' : `/${path}`}`
83
+ ipfsPath: `/ipfs/${cid}${parsed.url.pathname}`
102
84
  }
103
85
  }
104
86
 
105
- async resolveDNSLink (domain: string, path: string, query: Record<string, any>, options?: ResolveURLOptions): Promise<ResolveURLResult> {
87
+ async resolveDNSLink (domain: string, parsed: ParsedURL, options?: ResolveURLOptions): Promise<ResolveURLResult> {
106
88
  const results = await this.components.timing.time('dnsLink.resolve', `Resolve DNSLink ${domain}`, this.components.dnsLink.resolve(domain, options))
107
89
  const result = results?.[0]
108
90
 
@@ -112,7 +94,7 @@ export class URLResolver implements URLResolverInterface {
112
94
 
113
95
  // dnslink resolved to IPNS name
114
96
  if (result.namespace === 'ipns') {
115
- return this.resolveIPNSName(domain, result.peerId, path, query, options)
97
+ return this.resolveIPNSName(domain, result.peerId, parsed, options)
116
98
  }
117
99
 
118
100
  // dnslink resolved to CID
@@ -122,38 +104,37 @@ export class URLResolver implements URLResolverInterface {
122
104
  }
123
105
 
124
106
  return {
107
+ url: parsed.url,
125
108
  cid: result.cid,
126
- path: concatPaths(result.path, path),
109
+ path: concatPaths(...(result.path ?? '').split('/'), ...(parsed.path ?? [])),
110
+ fragment: parsed.fragment,
127
111
  // dnslink is mutable so return 'ipns' protocol so we do not include immutable in cache-control header
128
112
  protocol: 'ipns',
129
113
  ttl: result.answer.TTL,
130
- query,
131
- ipfsPath: `/ipns/${domain}${path === '' ? '' : `/${path}`}`
114
+ query: parsed.query,
115
+ ipfsPath: `/ipns/${domain}${parsed.url.pathname}`
132
116
  }
133
117
  }
134
118
 
135
- async resolveIPNSName (resource: string, key: PeerId, path: string, query: Record<string, any>, options?: AbortOptions): Promise<ResolveURLResult> {
119
+ async resolveIPNSName (resource: string, key: PeerId, parsed: Partial<ParsedURL> & Pick<ParsedURL, 'url'>, options?: AbortOptions): Promise<ResolveURLResult> {
136
120
  const result = await this.components.timing.time('ipns.resolve', `Resolve IPNS name ${key}`, this.components.ipnsResolver.resolve(key, options))
137
121
 
138
122
  return {
123
+ url: parsed.url,
139
124
  cid: result.cid,
140
- path: concatPaths(result.path, path),
141
- query,
125
+ path: concatPaths(...(result.path ?? '').split('/'), ...(parsed.path ?? [])),
126
+ query: parsed.query ?? {},
127
+ fragment: parsed.fragment ?? '',
142
128
  protocol: 'ipns',
143
129
  // IPNS ttl is in nanoseconds, convert to seconds
144
130
  ttl: Number((result.record.ttl ?? 0n) / BigInt(1e9)),
145
- ipfsPath: `/ipns/${resource}${path === '' ? '' : `/${path}`}`
131
+ ipfsPath: `/ipns/${resource}${parsed.url.pathname}`
146
132
  }
147
133
  }
148
134
  }
149
135
 
150
- function concatPaths (...paths: Array<string | undefined>): string {
151
- return `${
152
- paths
153
- .filter(p => p != null && p !== '')
154
- .join('/')
155
- .replaceAll(/(\/+)/g, '/')
156
- .replace(/^(\/)+/, '')
157
- .replace(/(\/)+$/, '/')
158
- }`
136
+ function concatPaths (...paths: Array<string | undefined>): string[] {
137
+ // @ts-expect-error undefined is filtered out
138
+ return paths
139
+ .filter(p => p != null && p !== '')
159
140
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * For DNSLink see https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header
3
+ * DNSLink names include . which means they must be inlined into a single DNS label to provide unique origin and work with wildcard TLS certificates.
4
+ */
5
+
6
+ // DNS label can have up to 63 characters, consisting of alphanumeric
7
+ // characters or hyphens -, but it must not start or end with a hyphen.
8
+ const dnsLabelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
9
+
10
+ /**
11
+ * Checks if label looks like inlined DNSLink.
12
+ * (https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header)
13
+ */
14
+ export function isInlinedDnsLink (label: string): boolean {
15
+ return dnsLabelRegex.test(label) && label.includes('-') && !label.includes('.')
16
+ }
17
+
18
+ /**
19
+ * DNSLink label decoding
20
+ * - Every standalone - is replaced with .
21
+ * - Every remaining -- is replaced with -
22
+ *
23
+ * @example en-wikipedia--on--ipfs-org -> en.wikipedia-on-ipfs.org
24
+ */
25
+ export function decodeDNSLinkLabel (label: string): string {
26
+ return label.replace(/--/g, '%').replace(/-/g, '.').replace(/%/g, '-')
27
+ }
28
+
29
+ /**
30
+ * DNSLink label encoding
31
+ * - Every - is replaced with --
32
+ * - Every . is replaced with -
33
+ *
34
+ * @example en.wikipedia-on-ipfs.org -> en-wikipedia--on--ipfs-org
35
+ */
36
+ export function encodeDNSLinkLabel (name: string): string {
37
+ return name.replace(/-/g, '--').replace(/\./g, '-')
38
+ }
@@ -5,7 +5,7 @@ import type { Logger } from '@libp2p/interface'
5
5
 
6
6
  export interface GetContentTypeOptions {
7
7
  bytes: Uint8Array
8
- path?: string
8
+ path?: string[]
9
9
  defaultContentType?: string
10
10
  contentTypeParser?: ContentTypeParser
11
11
  log: Logger
@@ -25,7 +25,7 @@ export async function getContentType ({ bytes, path, contentTypeParser, log, def
25
25
  try {
26
26
  let fileName
27
27
  if (filenameParam == null) {
28
- fileName = path?.split('/').pop()?.trim()
28
+ fileName = path?.[path.length - 1]?.trim()
29
29
  fileName = (fileName === '' || fileName?.split('.').length === 1) ? undefined : fileName
30
30
  } else {
31
31
  fileName = filenameParam
@@ -2,18 +2,15 @@ import { AbortError } from '@libp2p/interface'
2
2
  import { CustomProgressEvent } from 'progress-events'
3
3
  import { NoContentError } from '../errors.js'
4
4
  import type { VerifiedFetchInit } from '../index.js'
5
- import type { Logger } from '@libp2p/interface'
6
5
 
7
6
  /**
8
7
  * Converts an async iterator of Uint8Array bytes to a stream and returns the first chunk of bytes
9
8
  */
10
- export async function getStreamFromAsyncIterable (iterator: AsyncIterable<Uint8Array>, path: string, logger: Logger, options?: Pick<VerifiedFetchInit, 'onProgress' | 'signal'>): Promise<{ stream: ReadableStream<Uint8Array>, firstChunk: Uint8Array }> {
11
- const log = logger.newScope('get-stream-from-async-iterable')
9
+ export async function getStreamFromAsyncIterable (iterator: AsyncIterable<Uint8Array>, options?: Pick<VerifiedFetchInit, 'onProgress' | 'signal'>): Promise<{ stream: ReadableStream<Uint8Array>, firstChunk: Uint8Array }> {
12
10
  const reader = iterator[Symbol.asyncIterator]()
13
11
  const { value: firstChunk, done } = await reader.next()
14
12
 
15
13
  if (done === true) {
16
- log.error('no content found for path "%s"', path)
17
14
  throw new NoContentError()
18
15
  }
19
16
 
@@ -1,5 +1,5 @@
1
1
  import { SubdomainNotSupportedError } from '../errors.js'
2
- import { matchURLString } from './parse-url-string.js'
2
+ import { parseURLString } from './parse-url-string.js'
3
3
  import { movedPermanentlyResponse } from './responses.js'
4
4
  import type { VerifiedFetchInit, Resource } from '../index.js'
5
5
  import type { AbortOptions, ComponentLogger } from '@libp2p/interface'
@@ -47,7 +47,7 @@ export async function getRedirectResponse ({ resource, options, logger, cid, fet
47
47
  // if x-forwarded-host is passed, we need to set the location header to the
48
48
  // subdomain so that the browser can redirect to the correct subdomain
49
49
  try {
50
- const urlParts = matchURLString(resource)
50
+ const urlParts = parseURLString(resource)
51
51
  const reqUrl = new URL(resource)
52
52
  const actualHost = forwardedHost ?? reqUrl.host
53
53
  const subdomainUrl = new URL(reqUrl)
@@ -0,0 +1,54 @@
1
+ import { InvalidParametersError } from '@libp2p/interface'
2
+
3
+ /**
4
+ * Turns an IPFS or IPNS path into a HTTP URL. Path gateway syntax is used to
5
+ * preserve any case sensitivity
6
+ *
7
+ * - `/ipfs/cid` -> `https://example.org/ipfs/cid`
8
+ * - `/ipns/name` -> `https://example.org/ipns/name`
9
+ */
10
+ export function ipfsPathToUrl (path: string): string {
11
+ if (!path.startsWith('/ipfs/') && !path.startsWith('/ipns/')) {
12
+ throw new InvalidParametersError(`Path ${path} did not start with /ipfs/ or /ipns/`)
13
+ }
14
+
15
+ // trim fragment
16
+ const fragmentIndex = path.indexOf('#')
17
+ let fragment = ''
18
+
19
+ if (fragmentIndex > -1) {
20
+ fragment = path.substring(fragmentIndex)
21
+ path = path.substring(0, fragmentIndex)
22
+ }
23
+
24
+ // trim query
25
+ const queryIndex = path.indexOf('?')
26
+ let query = ''
27
+
28
+ if (queryIndex > -1) {
29
+ query = path.substring(queryIndex)
30
+ path = path.substring(0, queryIndex)
31
+ }
32
+
33
+ const type = path.substring(1, 5)
34
+ const rest = path.substring(6)
35
+
36
+ return `https://example.org/${type}/${rest}${query}${fragment}`
37
+ }
38
+
39
+ /**
40
+ * Turns an IPFS or IPNS URL into a HTTP URL. Path gateway syntax is used to
41
+ * preserve and case sensitivity
42
+ *
43
+ * `ipfs://cid` -> `https://example.org/ipfs/cid`
44
+ */
45
+ export function ipfsUrlToUrl (url: string): string {
46
+ if (!url.startsWith('ipfs://') && !url.startsWith('ipns://')) {
47
+ throw new InvalidParametersError(`URL ${url} did not start with ipfs:// or ipns://`)
48
+ }
49
+
50
+ const type = url.substring(0, 4)
51
+ const rest = url.substring(7)
52
+
53
+ return `https://example.org/${type}/${rest}`
54
+ }