@helia/verified-fetch 4.0.1 → 4.0.3

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 +48 -48
  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 +35 -25
  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 +2 -2
  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 +42 -24
  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 +3 -3
  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
@@ -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
@@ -41,7 +41,7 @@ export async function getContentType ({ bytes, path, contentTypeParser, log, def
41
41
  } else if (parsed != null) {
42
42
  contentType = parsed
43
43
  }
44
- log.trace('contentTypeParser returned %s', contentType)
44
+ log.trace('contentTypeParser returned %s for file with name %s', contentType, fileName)
45
45
  } catch (err) {
46
46
  log.error('error parsing content type', err)
47
47
  }
@@ -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
+ }
@@ -1,77 +1,194 @@
1
- const URL_REGEX = /^(?<protocol>ip[fn]s):\/\/(?<cidOrPeerIdOrDnsLink>[^/?]+)\/?(?<path>[^?]*)\??(?<query>.*)$/
2
- const PATH_REGEX = /^\/(?<protocol>ip[fn]s)\/(?<cidOrPeerIdOrDnsLink>[^/?]+)\/?(?<path>[^?]*)\??(?<query>.*)$/
3
- const PATH_GATEWAY_REGEX = /^https?:\/\/(.*[^/])\/(?<protocol>ip[fn]s)\/(?<cidOrPeerIdOrDnsLink>[^/?]+)\/?(?<path>[^?]*)\??(?<query>.*)$/
4
- const SUBDOMAIN_GATEWAY_REGEX = /^https?:\/\/(?<cidOrPeerIdOrDnsLink>[^/?]+)\.(?<protocol>ip[fn]s)\.([^/?]+)\/?(?<path>[^?]*)\??(?<query>.*)$/
1
+ import { InvalidParametersError } from '@libp2p/interface'
2
+ import { decodeDNSLinkLabel, isInlinedDnsLink } from './dnslink-label.ts'
3
+ import { ipfsPathToUrl, ipfsUrlToUrl } from './ipfs-path-to-url.ts'
5
4
 
6
- interface MatchUrlGroups {
5
+ interface SubdomainMatchGroups {
7
6
  protocol: 'ipfs' | 'ipns'
8
7
  cidOrPeerIdOrDnsLink: string
9
- path?: string
10
- query?: string
11
8
  }
12
9
 
13
- function matchUrlGroupsGuard (groups?: null | { [key in string]: string; } | MatchUrlGroups): groups is MatchUrlGroups {
10
+ const SUBDOMAIN_GATEWAY_REGEX = /^(?<cidOrPeerIdOrDnsLink>[^/?]+)\.(?<protocol>ip[fn]s)\.([^/?]+)$/
11
+
12
+ function matchSubdomainGroupsGuard (groups?: null | { [key in string]: string; } | SubdomainMatchGroups): groups is SubdomainMatchGroups {
14
13
  const protocol = groups?.protocol
15
- if (protocol == null) { return false }
14
+
15
+ if (protocol !== 'ipfs' && protocol !== 'ipns') {
16
+ return false
17
+ }
18
+
16
19
  const cidOrPeerIdOrDnsLink = groups?.cidOrPeerIdOrDnsLink
17
- if (cidOrPeerIdOrDnsLink == null) { return false }
18
- const path = groups?.path
19
- const query = groups?.query
20
-
21
- return ['ipns', 'ipfs'].includes(protocol) &&
22
- typeof cidOrPeerIdOrDnsLink === 'string' &&
23
- (path == null || typeof path === 'string') &&
24
- (query == null || typeof query === 'string')
20
+
21
+ if (cidOrPeerIdOrDnsLink == null) {
22
+ return false
23
+ }
24
+
25
+ return true
25
26
  }
26
27
 
27
- // TODO: can this be replaced with `new URL`?
28
- export function matchURLString (urlString: string): MatchUrlGroups {
29
- for (const pattern of [SUBDOMAIN_GATEWAY_REGEX, URL_REGEX, PATH_GATEWAY_REGEX, PATH_REGEX]) {
30
- const match = urlString.match(pattern)
28
+ export interface ParsedURL {
29
+ url: URL,
30
+ protocol: 'ipfs' | 'ipns'
31
+ cidOrPeerIdOrDnsLink: string
32
+ path: string[]
33
+ query: Record<string, any>
34
+ fragment: string
35
+ }
31
36
 
32
- if (matchUrlGroupsGuard(match?.groups)) {
33
- const groups = match.groups satisfies MatchUrlGroups
37
+ function toQuery (query?: URLSearchParams): Record<string, any> {
38
+ if (query == null) {
39
+ return {}
40
+ }
34
41
 
35
- if (groups.path != null) {
36
- groups.path = decodeURIComponent(groups.path)
37
- }
42
+ const output: Record<string, any> = {}
38
43
 
39
- // decode inline dnslink domain if present
40
- if (pattern === SUBDOMAIN_GATEWAY_REGEX && groups.protocol === 'ipns' && isInlinedDnsLink(groups.cidOrPeerIdOrDnsLink)) {
41
- groups.cidOrPeerIdOrDnsLink = dnsLinkLabelDecoder(groups.cidOrPeerIdOrDnsLink)
42
- }
44
+ for (const [key, value] of query.entries()) {
45
+ output[key] = value
46
+
47
+ if (value === 'true') {
48
+ output[key] = true
49
+ }
43
50
 
44
- return groups
51
+ if (value === 'false') {
52
+ output[key] = false
45
53
  }
46
54
  }
47
55
 
48
- throw new TypeError(`Invalid URL: ${urlString}, please use ipfs://, ipns://, or gateway URLs only`)
56
+ return output
49
57
  }
50
58
 
51
- /**
52
- * For DNSLink see https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header
53
- * DNSLink names include . which means they must be inlined into a single DNS label to provide unique origin and work with wildcard TLS certificates.
54
- */
59
+ function stripLeadingHash (pathname: string): string {
60
+ return stripLeading(pathname, '#')
61
+ }
55
62
 
56
- // DNS label can have up to 63 characters, consisting of alphanumeric
57
- // characters or hyphens -, but it must not start or end with a hyphen.
58
- const dnsLabelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
63
+ function stripLeading (str: string, char: string): string {
64
+ while (str.startsWith(char)) {
65
+ str = str.substring(1)
66
+ }
67
+
68
+ return str
69
+ }
59
70
 
60
71
  /**
61
- * Checks if label looks like inlined DNSLink.
62
- * (https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header)
72
+ * If the caller has passed a case-sensitive identifier (like a base58btc
73
+ * encoded CID or PeerId) in a case-insensitive location (like a subdomain),
74
+ * be nice and return the original identifier from the passed string
63
75
  */
64
- function isInlinedDnsLink (label: string): boolean {
65
- return dnsLabelRegex.test(label) && label.includes('-') && !label.includes('.')
76
+ function findOriginalCidOrPeer (needle: string, haystack: string): string {
77
+ const start = haystack.toLowerCase().indexOf(needle)
78
+
79
+ if (start === -1) {
80
+ return needle
81
+ }
82
+
83
+ return haystack.substring(start, start + needle.length)
84
+ }
85
+
86
+ function toUrl (urlString: string): URL {
87
+ // turn IPFS/IPNS path into gateway URL string
88
+ if (urlString.startsWith('/ipfs/') || urlString.startsWith('/ipns/')) {
89
+ urlString = ipfsPathToUrl(urlString)
90
+ }
91
+
92
+ // turn IPFS/IPNS URL into gateway URL string
93
+ if (urlString.startsWith('ipfs://') || urlString.startsWith('ipns://')) {
94
+ urlString = ipfsUrlToUrl(urlString)
95
+ }
96
+
97
+ if (urlString.startsWith('http://') || urlString.startsWith('https://')) {
98
+ return new URL(urlString)
99
+ }
100
+
101
+ throw new InvalidParametersError(`Invalid URL: ${urlString}`)
66
102
  }
67
103
 
68
104
  /**
69
- * DNSLink label decoding
70
- * - Every standalone - is replaced with .
71
- * - Every remaining -- is replaced with -
105
+ * Accepts the following url strings:
72
106
  *
73
- * @example en-wikipedia--on--ipfs-org.ipns.example.net -> example.net/ipns/en.wikipedia-on-ipfs.org
107
+ * - /ipfs/Qmfoo/path
108
+ * - /ipns/Qmfoo/path
109
+ * - ipfs://cid/path
110
+ * - ipns://name/path
111
+ * - http://cid.ipfs.example.com/path
112
+ * - http://name.ipns.example.com/path
113
+ * - http://example.com/ipfs/cid/path
114
+ * - http://example.com/ipns/name/path
74
115
  */
75
- function dnsLinkLabelDecoder (linkLabel: string): string {
76
- return linkLabel.replace(/--/g, '%').replace(/-/g, '.').replace(/%/g, '-')
116
+ export function parseURLString (urlString: string): ParsedURL {
117
+ // validate url
118
+ const url = toUrl(urlString)
119
+
120
+ // test for subdomain gateway URL
121
+ const subdomainMatch = url.hostname.match(SUBDOMAIN_GATEWAY_REGEX)
122
+
123
+ if (matchSubdomainGroupsGuard(subdomainMatch?.groups)) {
124
+ const groups = subdomainMatch.groups
125
+
126
+ if (groups.protocol === 'ipns' && isInlinedDnsLink(groups.cidOrPeerIdOrDnsLink)) {
127
+ // decode inline dnslink domain if present
128
+ groups.cidOrPeerIdOrDnsLink = decodeDNSLinkLabel(groups.cidOrPeerIdOrDnsLink)
129
+ }
130
+
131
+ const cidOrPeerIdOrDnsLink = findOriginalCidOrPeer(groups.cidOrPeerIdOrDnsLink, urlString)
132
+
133
+ // parse url as not http(s):// - this is necessary because URL makes
134
+ // `.pathname` default to `/` for http URLs, even if no trailing slash was
135
+ // present in the string URL and we need to be able to round-trip the user's
136
+ // input while also maintaining a sane canonical URL for the resource. Phew.
137
+ const wat = new URL(`not-${urlString}`)
138
+
139
+ return {
140
+ url: new URL(`${groups.protocol}://${cidOrPeerIdOrDnsLink}${wat.pathname}${url.search}${url.hash}`),
141
+ protocol: groups.protocol,
142
+ cidOrPeerIdOrDnsLink,
143
+ path: url.pathname.split('/')
144
+ .filter(str => str !== '')
145
+ .map(str => decodeURIComponent(str)),
146
+ query: toQuery(url.searchParams),
147
+ fragment: stripLeadingHash(url.hash)
148
+ }
149
+ }
150
+
151
+ // test for IPFS path gateway URL
152
+ if (url.pathname.startsWith('/ipfs/')) {
153
+ const parts = url.pathname.substring(6).split('/')
154
+ const cid = parts.shift()
155
+
156
+ if (cid == null) {
157
+ throw new InvalidParametersError(`Path gateway URL ${urlString} had no CID`)
158
+ }
159
+
160
+ return {
161
+ url: new URL(`ipfs://${cid}${url.pathname.replace(`/ipfs/${cid}`, '')}${url.search}${url.hash}`),
162
+ protocol: 'ipfs',
163
+ cidOrPeerIdOrDnsLink: cid,
164
+ path: parts
165
+ .filter(str => str !== '')
166
+ .map(str => decodeURIComponent(str)),
167
+ query: toQuery(url.searchParams),
168
+ fragment: stripLeadingHash(url.hash)
169
+ }
170
+ }
171
+
172
+ // test for IPNS path gateway URL
173
+ if (url.pathname.startsWith('/ipns/')) {
174
+ const parts = url.pathname.substring(6).split('/')
175
+ const name = parts.shift()
176
+
177
+ if (name == null) {
178
+ throw new InvalidParametersError(`Path gateway URL ${urlString} had no name`)
179
+ }
180
+
181
+ return {
182
+ url: new URL(`ipns://${name}${url.pathname.replace(`/ipns/${name}`, '')}${url.search}${url.hash}`),
183
+ protocol: 'ipns',
184
+ cidOrPeerIdOrDnsLink: name,
185
+ path: parts
186
+ .filter(str => str !== '')
187
+ .map(str => decodeURIComponent(str)),
188
+ query: toQuery(url.searchParams),
189
+ fragment: stripLeadingHash(url.hash)
190
+ }
191
+ }
192
+
193
+ throw new TypeError(`Invalid URL: ${urlString}, please use ipfs://, ipns://, or gateway URLs only`)
77
194
  }
@@ -1,5 +1,5 @@
1
1
  import { CID } from 'multiformats/cid'
2
- import { matchURLString } from './parse-url-string.js'
2
+ import { parseURLString } from './parse-url-string.js'
3
3
 
4
4
  /**
5
5
  * Takes a resource and returns a session cache key as an IPFS or IPNS path with
@@ -24,7 +24,7 @@ export function resourceToSessionCacheKey (url: string | CID): string {
24
24
  return `ipfs://${CID.parse(url.toString())}`
25
25
  } catch {}
26
26
 
27
- const { protocol, cidOrPeerIdOrDnsLink } = matchURLString(url.toString())
27
+ const { protocol, cidOrPeerIdOrDnsLink } = parseURLString(url.toString())
28
28
 
29
29
  return `${protocol}://${cidOrPeerIdOrDnsLink}`
30
30
  }
@@ -16,6 +16,12 @@ function setType (response: Response, value: 'basic' | 'cors' | 'error' | 'opaqu
16
16
  }
17
17
 
18
18
  function setUrl (response: Response, value: string): void {
19
+ const fragmentStart = value.indexOf('#')
20
+
21
+ if (fragmentStart > -1) {
22
+ value = value.substring(0, fragmentStart)
23
+ }
24
+
19
25
  setField(response, 'url', value)
20
26
  }
21
27
 
@@ -52,7 +52,7 @@ export function isObjectNode (node: UnixFSEntry): node is ObjectNode {
52
52
  */
53
53
  export async function handlePathWalking ({ cid, path, resource, options, blockstore, log }: PluginContext & { blockstore: Blockstore, log: Logger }): Promise<PathWalkerResponse | Response> {
54
54
  try {
55
- return await walkPath(blockstore, `${cid}/${path}`, options)
55
+ return await walkPath(blockstore, `${cid}/${path.join('/')}`, options)
56
56
  } catch (err: any) {
57
57
  if (options?.signal?.aborted) {
58
58
  throw new AbortError(options?.signal?.reason)