@helia/verified-fetch 3.2.3 → 4.0.1

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 (165) hide show
  1. package/README.md +10 -52
  2. package/dist/index.min.js +86 -71
  3. package/dist/index.min.js.map +4 -4
  4. package/dist/src/constants.d.ts +2 -0
  5. package/dist/src/constants.d.ts.map +1 -0
  6. package/dist/src/constants.js +2 -0
  7. package/dist/src/constants.js.map +1 -0
  8. package/dist/src/index.d.ts +63 -61
  9. package/dist/src/index.d.ts.map +1 -1
  10. package/dist/src/index.js +12 -54
  11. package/dist/src/index.js.map +1 -1
  12. package/dist/src/plugins/index.d.ts +0 -1
  13. package/dist/src/plugins/index.d.ts.map +1 -1
  14. package/dist/src/plugins/index.js +0 -1
  15. package/dist/src/plugins/index.js.map +1 -1
  16. package/dist/src/plugins/plugin-base.d.ts.map +1 -1
  17. package/dist/src/plugins/plugin-base.js +3 -2
  18. package/dist/src/plugins/plugin-base.js.map +1 -1
  19. package/dist/src/plugins/plugin-handle-car.d.ts.map +1 -1
  20. package/dist/src/plugins/plugin-handle-car.js +37 -28
  21. package/dist/src/plugins/plugin-handle-car.js.map +1 -1
  22. package/dist/src/plugins/plugin-handle-dag-cbor-html-preview.d.ts +1 -1
  23. package/dist/src/plugins/plugin-handle-dag-cbor-html-preview.d.ts.map +1 -1
  24. package/dist/src/plugins/plugin-handle-dag-cbor-html-preview.js +1 -2
  25. package/dist/src/plugins/plugin-handle-dag-cbor-html-preview.js.map +1 -1
  26. package/dist/src/plugins/plugin-handle-dag-cbor.d.ts.map +1 -1
  27. package/dist/src/plugins/plugin-handle-dag-cbor.js +5 -6
  28. package/dist/src/plugins/plugin-handle-dag-cbor.js.map +1 -1
  29. package/dist/src/plugins/plugin-handle-dag-pb.d.ts.map +1 -1
  30. package/dist/src/plugins/plugin-handle-dag-pb.js +24 -27
  31. package/dist/src/plugins/plugin-handle-dag-pb.js.map +1 -1
  32. package/dist/src/plugins/plugin-handle-dag-walk.d.ts +8 -4
  33. package/dist/src/plugins/plugin-handle-dag-walk.d.ts.map +1 -1
  34. package/dist/src/plugins/plugin-handle-dag-walk.js +13 -9
  35. package/dist/src/plugins/plugin-handle-dag-walk.js.map +1 -1
  36. package/dist/src/plugins/plugin-handle-ipns-record.d.ts +1 -1
  37. package/dist/src/plugins/plugin-handle-ipns-record.d.ts.map +1 -1
  38. package/dist/src/plugins/plugin-handle-ipns-record.js +16 -24
  39. package/dist/src/plugins/plugin-handle-ipns-record.js.map +1 -1
  40. package/dist/src/plugins/plugin-handle-json.d.ts.map +1 -1
  41. package/dist/src/plugins/plugin-handle-json.js +5 -5
  42. package/dist/src/plugins/plugin-handle-json.js.map +1 -1
  43. package/dist/src/plugins/plugin-handle-raw.d.ts.map +1 -1
  44. package/dist/src/plugins/plugin-handle-raw.js +21 -12
  45. package/dist/src/plugins/plugin-handle-raw.js.map +1 -1
  46. package/dist/src/plugins/plugin-handle-tar.d.ts.map +1 -1
  47. package/dist/src/plugins/plugin-handle-tar.js +1 -2
  48. package/dist/src/plugins/plugin-handle-tar.js.map +1 -1
  49. package/dist/src/plugins/types.d.ts +15 -15
  50. package/dist/src/plugins/types.d.ts.map +1 -1
  51. package/dist/src/url-resolver.d.ts +21 -0
  52. package/dist/src/url-resolver.d.ts.map +1 -0
  53. package/dist/src/url-resolver.js +118 -0
  54. package/dist/src/url-resolver.js.map +1 -0
  55. package/dist/src/utils/byte-range-context.d.ts +3 -3
  56. package/dist/src/utils/byte-range-context.d.ts.map +1 -1
  57. package/dist/src/utils/byte-range-context.js +1 -1
  58. package/dist/src/utils/byte-range-context.js.map +1 -1
  59. package/dist/src/utils/content-type-parser.d.ts.map +1 -1
  60. package/dist/src/utils/content-type-parser.js +0 -10
  61. package/dist/src/utils/content-type-parser.js.map +1 -1
  62. package/dist/src/utils/error-to-object.d.ts +6 -0
  63. package/dist/src/utils/error-to-object.d.ts.map +1 -0
  64. package/dist/src/utils/error-to-object.js +20 -0
  65. package/dist/src/utils/error-to-object.js.map +1 -0
  66. package/dist/src/utils/get-content-type.d.ts +3 -3
  67. package/dist/src/utils/get-content-type.d.ts.map +1 -1
  68. package/dist/src/utils/get-content-type.js +1 -1
  69. package/dist/src/utils/get-content-type.js.map +1 -1
  70. package/dist/src/utils/get-e-tag.d.ts +1 -1
  71. package/dist/src/utils/get-offset-and-length.d.ts +6 -0
  72. package/dist/src/utils/get-offset-and-length.d.ts.map +1 -0
  73. package/dist/src/utils/get-offset-and-length.js +46 -0
  74. package/dist/src/utils/get-offset-and-length.js.map +1 -0
  75. package/dist/src/utils/get-resolved-accept-header.d.ts +2 -2
  76. package/dist/src/utils/get-resolved-accept-header.d.ts.map +1 -1
  77. package/dist/src/utils/get-stream-from-async-iterable.d.ts +2 -2
  78. package/dist/src/utils/get-stream-from-async-iterable.d.ts.map +1 -1
  79. package/dist/src/utils/get-stream-from-async-iterable.js +2 -2
  80. package/dist/src/utils/get-stream-from-async-iterable.js.map +1 -1
  81. package/dist/src/utils/handle-redirects.d.ts.map +1 -1
  82. package/dist/src/utils/handle-redirects.js +3 -3
  83. package/dist/src/utils/handle-redirects.js.map +1 -1
  84. package/dist/src/utils/ipfs-path-to-string.d.ts +6 -0
  85. package/dist/src/utils/ipfs-path-to-string.d.ts.map +1 -0
  86. package/dist/src/utils/ipfs-path-to-string.js +10 -0
  87. package/dist/src/utils/ipfs-path-to-string.js.map +1 -0
  88. package/dist/src/utils/is-accept-explicit.d.ts +6 -4
  89. package/dist/src/utils/is-accept-explicit.d.ts.map +1 -1
  90. package/dist/src/utils/is-accept-explicit.js +7 -4
  91. package/dist/src/utils/is-accept-explicit.js.map +1 -1
  92. package/dist/src/utils/parse-url-string.d.ts +1 -55
  93. package/dist/src/utils/parse-url-string.d.ts.map +1 -1
  94. package/dist/src/utils/parse-url-string.js +16 -217
  95. package/dist/src/utils/parse-url-string.js.map +1 -1
  96. package/dist/src/utils/response-headers.d.ts +1 -1
  97. package/dist/src/utils/response-headers.d.ts.map +1 -1
  98. package/dist/src/utils/responses.d.ts +3 -2
  99. package/dist/src/utils/responses.d.ts.map +1 -1
  100. package/dist/src/utils/responses.js +12 -1
  101. package/dist/src/utils/responses.js.map +1 -1
  102. package/dist/src/utils/select-output-type.d.ts +6 -2
  103. package/dist/src/utils/select-output-type.d.ts.map +1 -1
  104. package/dist/src/utils/select-output-type.js +28 -37
  105. package/dist/src/utils/select-output-type.js.map +1 -1
  106. package/dist/src/utils/server-timing.d.ts +5 -11
  107. package/dist/src/utils/server-timing.d.ts.map +1 -1
  108. package/dist/src/utils/server-timing.js +17 -15
  109. package/dist/src/utils/server-timing.js.map +1 -1
  110. package/dist/src/utils/walk-path.js +2 -2
  111. package/dist/src/utils/walk-path.js.map +1 -1
  112. package/dist/src/verified-fetch.d.ts +3 -10
  113. package/dist/src/verified-fetch.d.ts.map +1 -1
  114. package/dist/src/verified-fetch.js +99 -80
  115. package/dist/src/verified-fetch.js.map +1 -1
  116. package/dist/typedoc-urls.json +13 -4
  117. package/package.json +35 -36
  118. package/src/constants.ts +1 -0
  119. package/src/index.ts +79 -70
  120. package/src/plugins/index.ts +0 -1
  121. package/src/plugins/plugin-base.ts +3 -2
  122. package/src/plugins/plugin-handle-car.ts +53 -31
  123. package/src/plugins/plugin-handle-dag-cbor-html-preview.ts +4 -3
  124. package/src/plugins/plugin-handle-dag-cbor.ts +8 -6
  125. package/src/plugins/plugin-handle-dag-pb.ts +34 -26
  126. package/src/plugins/plugin-handle-dag-walk.ts +15 -9
  127. package/src/plugins/plugin-handle-ipns-record.ts +21 -24
  128. package/src/plugins/plugin-handle-json.ts +6 -5
  129. package/src/plugins/plugin-handle-raw.ts +27 -13
  130. package/src/plugins/plugin-handle-tar.ts +3 -2
  131. package/src/plugins/types.ts +18 -16
  132. package/src/url-resolver.ts +159 -0
  133. package/src/utils/byte-range-context.ts +4 -4
  134. package/src/utils/content-type-parser.ts +5 -11
  135. package/src/utils/error-to-object.ts +22 -0
  136. package/src/utils/get-content-type.ts +5 -4
  137. package/src/utils/get-e-tag.ts +1 -1
  138. package/src/utils/get-offset-and-length.ts +54 -0
  139. package/src/utils/get-resolved-accept-header.ts +2 -2
  140. package/src/utils/get-stream-from-async-iterable.ts +4 -4
  141. package/src/utils/handle-redirects.ts +10 -3
  142. package/src/utils/ipfs-path-to-string.ts +9 -0
  143. package/src/utils/is-accept-explicit.ts +14 -7
  144. package/src/utils/parse-url-string.ts +20 -286
  145. package/src/utils/response-headers.ts +1 -1
  146. package/src/utils/responses.ts +16 -2
  147. package/src/utils/select-output-type.ts +38 -44
  148. package/src/utils/server-timing.ts +17 -30
  149. package/src/utils/walk-path.ts +2 -2
  150. package/src/verified-fetch.ts +119 -92
  151. package/dist/src/plugins/errors.d.ts +0 -25
  152. package/dist/src/plugins/errors.d.ts.map +0 -1
  153. package/dist/src/plugins/errors.js +0 -33
  154. package/dist/src/plugins/errors.js.map +0 -1
  155. package/dist/src/types.d.ts +0 -16
  156. package/dist/src/types.d.ts.map +0 -1
  157. package/dist/src/types.js +0 -2
  158. package/dist/src/types.js.map +0 -1
  159. package/dist/src/utils/parse-resource.d.ts +0 -18
  160. package/dist/src/utils/parse-resource.d.ts.map +0 -1
  161. package/dist/src/utils/parse-resource.js +0 -27
  162. package/dist/src/utils/parse-resource.js.map +0 -1
  163. package/src/plugins/errors.ts +0 -37
  164. package/src/types.ts +0 -17
  165. package/src/utils/parse-resource.ts +0 -42
@@ -1,38 +1,44 @@
1
1
  import { code as dagCborCode } from '@ipld/dag-cbor'
2
2
  import { code as dagPbCode } from '@ipld/dag-pb'
3
+ import { CODEC_IDENTITY } from '../constants.ts'
3
4
  import { handlePathWalking } from '../utils/walk-path.js'
4
5
  import { BasePlugin } from './plugin-base.js'
5
6
  import type { PluginContext } from './types.js'
6
7
 
7
8
  /**
8
- * This plugin should almost always run first because it's going to handle path walking if needed, and will only say it can handle
9
- * the request if path walking is possible (path is not empty, terminalCid is unknown, and the path has not been walked yet).
9
+ * This plugin should almost always run first because it's going to handle path
10
+ * walking if needed, and will only say it can handle the request if path
11
+ * walking is possible (path is not empty, terminalCid is unknown, and the path
12
+ * has not been walked yet).
10
13
  *
11
- * Once this plugin has run, the PluginContext will be updated and then this plugin will return false for canHandle, so it won't run again.
14
+ * Once this plugin has run, the PluginContext will be updated and then this
15
+ * plugin will return false for canHandle, so it won't run again.
12
16
  */
13
17
  export class DagWalkPlugin extends BasePlugin {
14
18
  readonly id = 'dag-walk-plugin'
19
+
15
20
  /**
16
- * Return false if the path has already been walked, otherwise return true if the CID is encoded with a codec that supports pathing.
21
+ * Return false if the path has already been walked, otherwise return true if
22
+ * the CID is encoded with a codec that supports pathing.
17
23
  */
18
24
  canHandle (context: PluginContext): boolean {
19
- this.log('checking if we can handle %c with accept %s', context.cid, context.accept)
20
25
  const { pathDetails, cid } = context
26
+
21
27
  if (pathDetails != null) {
22
28
  // path has already been walked
23
29
  return false
24
30
  }
25
31
 
26
- return (cid.code === dagPbCode || cid.code === dagCborCode)
32
+ return (cid.code === dagPbCode || cid.code === dagCborCode || cid.multihash.code === CODEC_IDENTITY)
27
33
  }
28
34
 
29
35
  async handle (context: PluginContext): Promise<Response | null> {
30
- const { cid, resource, options, withServerTiming = false } = context
31
- const { getBlockstore, handleServerTiming } = this.pluginOptions
36
+ const { cid, resource, options } = context
37
+ const { getBlockstore } = this.pluginOptions
32
38
  const blockstore = getBlockstore(cid, resource, options?.session ?? true, options)
33
39
 
34
40
  // TODO: migrate handlePathWalking into this plugin
35
- const pathDetails = await handleServerTiming('path-walking', '', async () => handlePathWalking({ ...context, blockstore, log: this.log }), withServerTiming)
41
+ const pathDetails = await context.serverTiming.time('path-walking', '', handlePathWalking({ ...context, blockstore, log: this.log }))
36
42
 
37
43
  if (pathDetails instanceof Response) {
38
44
  this.log.trace('path walking failed')
@@ -1,11 +1,6 @@
1
- import { Record as DHTRecord } from '@libp2p/kad-dht'
2
- import { Key } from 'interface-datastore'
3
- import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
4
- import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
5
- import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
1
+ import { marshalIPNSRecord } from 'ipns'
6
2
  import { getPeerIdFromString } from '../utils/get-peer-id-from-string.js'
7
3
  import { badRequestResponse, okRangeResponse } from '../utils/responses.js'
8
- import { PluginFatalError } from './errors.js'
9
4
  import { BasePlugin } from './plugin-base.js'
10
5
  import type { PluginContext } from './types.js'
11
6
  import type { PeerId } from '@libp2p/interface'
@@ -17,27 +12,30 @@ import type { PeerId } from '@libp2p/interface'
17
12
  export class IpnsRecordPlugin extends BasePlugin {
18
13
  readonly id = 'ipns-record-plugin'
19
14
  readonly codes = []
20
- canHandle ({ cid, accept, query, byteRangeContext }: PluginContext): boolean {
21
- this.log('checking if we can handle %c with accept %s', cid, accept)
15
+
16
+ canHandle ({ resource, accept, query, path, byteRangeContext }: PluginContext): boolean {
22
17
  if (byteRangeContext == null) {
23
18
  return false
24
19
  }
25
20
 
26
- return accept === 'application/vnd.ipfs.ipns-record' || query.format === 'ipns-record'
21
+ return accept?.mimeType === 'application/vnd.ipfs.ipns-record' || query.format === 'ipns-record'
27
22
  }
28
23
 
29
24
  async handle (context: PluginContext & Required<Pick<PluginContext, 'byteRangeContext'>>): Promise<Response> {
30
- const { resource, path, options } = context
31
- const { helia } = this.pluginOptions
25
+ const { resource, path, query, options } = context
26
+ const { ipnsResolver } = this.pluginOptions
32
27
  context.reqFormat = 'ipns-record'
28
+
33
29
  if (path !== '' || !(resource.startsWith('ipns://') || resource.includes('.ipns.') || resource.includes('/ipns/'))) {
34
30
  this.log.error('invalid request for IPNS name "%s" and path "%s"', resource, path)
35
- throw new PluginFatalError('ERR_INVALID_IPNS_NAME', 'Invalid IPNS name', { response: badRequestResponse(resource, new Error('Invalid IPNS name')) })
31
+ return badRequestResponse(resource, new Error('Invalid IPNS name'))
36
32
  }
33
+
37
34
  let peerId: PeerId
38
35
 
39
36
  try {
40
37
  let peerIdString: string
38
+
41
39
  if (resource.startsWith('ipns://')) {
42
40
  peerIdString = resource.replace('ipns://', '')
43
41
  } else if (resource.includes('/ipns/')) {
@@ -51,24 +49,23 @@ export class IpnsRecordPlugin extends BasePlugin {
51
49
  } catch (err: any) {
52
50
  this.log.error('could not parse peer id from IPNS url %s', resource, err)
53
51
 
54
- throw new PluginFatalError('ERR_NO_PEER_ID_FOUND', 'could not parse peer id from url', { response: badRequestResponse(resource, err) })
52
+ return badRequestResponse(resource, err)
55
53
  }
56
54
 
57
- // since this call happens after parseResource, we've already resolved the
58
- // IPNS name so a local copy should be in the helia datastore, so we can
59
- // just read it out..
60
- const routingKey = uint8ArrayConcat([
61
- uint8ArrayFromString('/ipns/'),
62
- peerId.toMultihash().bytes
63
- ])
64
- const datastoreKey = new Key('/dht/record/' + uint8ArrayToString(routingKey, 'base32'), false)
65
- const buf = await helia.datastore.get(datastoreKey, options)
66
- const record = DHTRecord.deserialize(buf)
55
+ // force download in handleFinalResponse
56
+ query.filename = query.filename ?? `${peerId}.bin`
57
+ query.download = true
58
+
59
+ // @ts-expect-error progress handler types are incompatible
60
+ const result = await ipnsResolver.resolve(peerId, options)
61
+ const buf = marshalIPNSRecord(result.record)
67
62
 
68
- context.byteRangeContext.setBody(record.value)
63
+ context.byteRangeContext.setBody(buf)
69
64
 
70
65
  const response = okRangeResponse(resource, context.byteRangeContext.getBody('application/vnd.ipfs.ipns-record'), { byteRangeContext: context.byteRangeContext, log: this.log })
71
66
  response.headers.set('content-type', context.byteRangeContext.getContentType() ?? 'application/vnd.ipfs.ipns-record')
67
+ response.headers.set('content-length', buf.byteLength.toString())
68
+ response.headers.set('x-ipfs-roots', result.cid.toV1().toString())
72
69
 
73
70
  return response
74
71
  }
@@ -1,5 +1,6 @@
1
1
  import * as ipldDagCbor from '@ipld/dag-cbor'
2
2
  import * as ipldDagJson from '@ipld/dag-json'
3
+ import toBuffer from 'it-to-buffer'
3
4
  import { code as jsonCode } from 'multiformats/codecs/json'
4
5
  import { notAcceptableResponse, okRangeResponse } from '../utils/responses.js'
5
6
  import { BasePlugin } from './plugin-base.js'
@@ -11,13 +12,13 @@ import type { PluginContext } from './types.js'
11
12
  export class JsonPlugin extends BasePlugin {
12
13
  readonly id = 'json-plugin'
13
14
  readonly codes = [ipldDagJson.code, jsonCode]
15
+
14
16
  canHandle ({ cid, accept, byteRangeContext }: PluginContext): boolean {
15
- this.log('checking if we can handle %c with accept %s', cid, accept)
16
17
  if (byteRangeContext == null) {
17
18
  return false
18
19
  }
19
20
 
20
- if (accept === 'application/vnd.ipld.dag-json' && cid.code !== ipldDagCbor.code) {
21
+ if (accept?.mimeType === 'application/vnd.ipld.dag-json' && cid.code !== ipldDagCbor.code) {
21
22
  // we can handle application/vnd.ipld.dag-json, but if the CID codec is ipldDagCbor, DagCborPlugin should handle it
22
23
  // TODO: remove the need for deny-listing cases in plugins
23
24
  return true
@@ -35,10 +36,10 @@ export class JsonPlugin extends BasePlugin {
35
36
 
36
37
  const terminalCid = context.pathDetails?.terminalElement.cid ?? context.cid
37
38
  const blockstore = getBlockstore(terminalCid, resource, session, options)
38
- const block = await blockstore.get(terminalCid, options)
39
+ const block = await toBuffer(blockstore.get(terminalCid, options))
39
40
  let body: string | Uint8Array
40
41
 
41
- if (accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
42
+ if (accept?.mimeType === 'application/vnd.ipld.dag-cbor' || accept?.mimeType === 'application/cbor') {
42
43
  try {
43
44
  // if vnd.ipld.dag-cbor has been specified, convert to the format - note
44
45
  // that this supports more data types than regular JSON, the content-type
@@ -62,7 +63,7 @@ export class JsonPlugin extends BasePlugin {
62
63
  contentType = 'application/json'
63
64
  }
64
65
  } else {
65
- contentType = accept.split(';')[0]
66
+ contentType = accept?.mimeType.split(';')[0]
66
67
  }
67
68
 
68
69
  context.byteRangeContext.setBody(body)
@@ -1,10 +1,11 @@
1
+ import toBuffer from 'it-to-buffer'
1
2
  import { code as rawCode } from 'multiformats/codecs/raw'
2
3
  import { identity } from 'multiformats/hashes/identity'
3
4
  import { getContentType } from '../utils/get-content-type.js'
4
5
  import { notFoundResponse, okRangeResponse } from '../utils/responses.js'
5
- import { PluginFatalError } from './errors.js'
6
6
  import { BasePlugin } from './plugin-base.js'
7
7
  import type { PluginContext } from './types.js'
8
+ import type { AcceptHeader } from '../utils/select-output-type.ts'
8
9
 
9
10
  /**
10
11
  * These are Accept header values that will cause content type sniffing to be
@@ -22,9 +23,9 @@ const RAW_HEADERS = [
22
23
  * type. This avoids the user from receiving something different when they
23
24
  * signal that they want to `Accept` a specific mime type.
24
25
  */
25
- function getOverriddenRawContentType ({ headers, accept }: { headers?: HeadersInit, accept?: string }): string | undefined {
26
+ function getOverriddenRawContentType ({ headers, accept }: { headers?: HeadersInit, accept?: AcceptHeader }): string | undefined {
26
27
  // accept has already been resolved by getResolvedAcceptHeader, if we have it, use it.
27
- const acceptHeader = accept ?? new Headers(headers).get('accept') ?? ''
28
+ const acceptHeader = accept?.mimeType ?? new Headers(headers).get('accept') ?? ''
28
29
 
29
30
  // e.g. "Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8"
30
31
  const acceptHeaders = acceptHeader.split(',')
@@ -47,11 +48,11 @@ export class RawPlugin extends BasePlugin {
47
48
  codes: number[] = [rawCode, identity.code]
48
49
 
49
50
  canHandle ({ cid, accept, query, byteRangeContext }: PluginContext): boolean {
50
- this.log('checking if we can handle %c with accept %s', cid, accept)
51
51
  if (byteRangeContext == null) {
52
52
  return false
53
53
  }
54
- return accept === 'application/vnd.ipld.raw' || query.format === 'raw'
54
+
55
+ return accept?.mimeType === 'application/vnd.ipld.raw' || query.format === 'raw'
55
56
  }
56
57
 
57
58
  async handle (context: PluginContext & Required<Pick<PluginContext, 'byteRangeContext'>>): Promise<Response> {
@@ -60,36 +61,49 @@ export class RawPlugin extends BasePlugin {
60
61
  const session = options?.session ?? true
61
62
  const log = this.log
62
63
 
63
- if (accept === 'application/vnd.ipld.raw' || query.format === 'raw') {
64
+ if (accept?.mimeType === 'application/vnd.ipld.raw' || query.format === 'raw') {
64
65
  context.reqFormat = 'raw'
65
66
  context.query.download = true
66
67
  context.query.filename = context.query.filename ?? `${cid.toString()}.bin`
67
- log.trace('Set content disposition...')
68
+ log.trace('set content disposition to force download')
68
69
  } else {
69
- log.trace('Did NOT set content disposition...')
70
+ log.trace('did not set content disposition, raw block will display inline')
70
71
  }
71
72
 
72
73
  if (path !== '' && cid.code === rawCode) {
73
74
  log.trace('404-ing raw codec request for %c/%s', cid, path)
74
- // throw new PluginError('ERR_RAW_PATHS_NOT_SUPPORTED', 'Raw codec does not support paths')
75
- // return notFoundResponse(resource, 'Raw codec does not support paths')
76
- throw new PluginFatalError('ERR_RAW_PATHS_NOT_SUPPORTED', 'Raw codec does not support paths', { response: notFoundResponse(resource, 'Raw codec does not support paths') })
75
+ return notFoundResponse(resource)
77
76
  }
78
77
 
79
78
  const terminalCid = context.pathDetails?.terminalElement.cid ?? context.cid
80
79
  const blockstore = getBlockstore(terminalCid, resource, session, options)
81
- const result = await blockstore.get(terminalCid, options)
80
+ const result = await toBuffer(blockstore.get(terminalCid, options))
81
+
82
82
  context.byteRangeContext.setBody(result)
83
83
 
84
84
  // if the user has specified an `Accept` header that corresponds to a raw
85
85
  // type, honour that header, so for example they don't request
86
86
  // `application/vnd.ipld.raw` but get `application/octet-stream`
87
- const contentType = await getContentType({ filename: query.filename, bytes: result, path, defaultContentType: getOverriddenRawContentType({ headers: options?.headers, accept }), contentTypeParser, log })
87
+ const contentType = await getContentType({
88
+ filename: query.filename,
89
+ bytes: result,
90
+ path,
91
+ defaultContentType: getOverriddenRawContentType({ headers: options?.headers, accept }),
92
+ contentTypeParser,
93
+ log
94
+ })
95
+
88
96
  const response = okRangeResponse(resource, context.byteRangeContext.getBody(contentType), { byteRangeContext: context.byteRangeContext, log }, {
89
97
  redirected: false
90
98
  })
91
99
 
92
100
  response.headers.set('content-type', context.byteRangeContext.getContentType() ?? contentType)
101
+ response.headers.set('x-ipfs-roots', terminalCid.toV1().toString())
102
+
103
+ // only set content-length if it is not a range request
104
+ if (!context.byteRangeContext.isRangeRequest) {
105
+ response.headers.set('content-length', result.byteLength.toString())
106
+ }
93
107
 
94
108
  return response
95
109
  }
@@ -14,12 +14,13 @@ import type { PluginContext } from './types.js'
14
14
  export class TarPlugin extends BasePlugin {
15
15
  readonly id = 'tar-plugin'
16
16
  readonly codes = []
17
+
17
18
  canHandle ({ cid, accept, query, byteRangeContext }: PluginContext): boolean {
18
- this.log('checking if we can handle %c with accept %s', cid, accept)
19
19
  if (byteRangeContext == null) {
20
20
  return false
21
21
  }
22
- return accept === 'application/x-tar' || query.format === 'tar'
22
+
23
+ return accept?.mimeType === 'application/x-tar' || query.format === 'tar'
23
24
  }
24
25
 
25
26
  async handle (context: PluginContext & Required<Pick<PluginContext, 'byteRangeContext'>>): Promise<Response> {
@@ -1,10 +1,10 @@
1
- import type { PluginError } from './errors.js'
2
- import type { VerifiedFetchInit } from '../index.js'
3
- import type { ContentTypeParser, RequestFormatShorthand } from '../types.js'
1
+ import type { ResolveURLResult, UrlQuery, VerifiedFetchInit, ContentTypeParser, RequestFormatShorthand } from '../index.js'
4
2
  import type { ByteRangeContext } from '../utils/byte-range-context.js'
5
- import type { ParsedUrlStringResults } from '../utils/parse-url-string.js'
3
+ import type { AcceptHeader } from '../utils/select-output-type.ts'
4
+ import type { ServerTiming } from '../utils/server-timing.ts'
6
5
  import type { PathWalkerResponse } from '../utils/walk-path.js'
7
- import type { AbortOptions, ComponentLogger, Logger } from '@libp2p/interface'
6
+ import type { IPNSResolver } from '@helia/ipns'
7
+ import type { AbortOptions, Logger } from '@libp2p/interface'
8
8
  import type { Helia } from 'helia'
9
9
  import type { Blockstore } from 'interface-blockstore'
10
10
  import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
@@ -17,11 +17,11 @@ import type { CustomProgressEvent } from 'progress-events'
17
17
  * - Persistent: Relevant even after the request completes (e.g., logging or metrics).
18
18
  */
19
19
  export interface PluginOptions {
20
- logger: ComponentLogger
20
+ logger: Logger
21
21
  getBlockstore(cid: CID, resource: string | CID, useSession?: boolean, options?: AbortOptions): Blockstore
22
- handleServerTiming<T>(name: string, description: string, fn: () => Promise<T>, withServerTiming: boolean): Promise<T>
23
22
  contentTypeParser?: ContentTypeParser
24
23
  helia: Helia
24
+ ipnsResolver: IPNSResolver
25
25
  }
26
26
 
27
27
  /**
@@ -30,30 +30,30 @@ export interface PluginOptions {
30
30
  * - Shared Data: Allows plugins to communicate partial results, discovered data, or interim errors.
31
31
  * - Ephemeral: Typically discarded once fetch(...) completes.
32
32
  */
33
- export interface PluginContext extends ParsedUrlStringResults {
33
+ export interface PluginContext extends ResolveURLResult {
34
34
  readonly cid: CID
35
35
  readonly path: string
36
36
  readonly resource: string
37
- readonly accept?: string
37
+ readonly accept?: AcceptHeader
38
38
 
39
39
  /**
40
40
  * An array of plugin IDs that are all enabled. You can use this to check if a plugin is enabled and respond accordingly.
41
41
  */
42
42
  plugins: string[]
43
+
43
44
  /**
44
45
  * The last time the context is modified, so we know whether a plugin has modified it.
45
46
  * A plugin should increment this value if it modifies the context.
46
47
  */
47
48
  modified: number
48
- withServerTiming?: boolean
49
49
  onProgress?(evt: CustomProgressEvent<any>): void
50
50
  options?: Omit<VerifiedFetchInit, 'signal'> & AbortOptions
51
51
  isDirectory?: boolean
52
52
  directoryEntries?: UnixFSEntry[]
53
- errors?: PluginError[]
54
53
  reqFormat?: RequestFormatShorthand
55
54
  pathDetails?: PathWalkerResponse
56
- query: ParsedUrlStringResults['query']
55
+ query: UrlQuery
56
+
57
57
  /**
58
58
  * ByteRangeContext contains information about the size of the content and range requests.
59
59
  * This can be used to set the Content-Length header without loading the entire body.
@@ -61,6 +61,12 @@ export interface PluginContext extends ParsedUrlStringResults {
61
61
  * This is set by the ByteRangeContextPlugin
62
62
  */
63
63
  byteRangeContext?: ByteRangeContext
64
+ serverTiming: ServerTiming
65
+ ipfsPath: string
66
+
67
+ /**
68
+ * Allow arbitrary keys/values
69
+ */
64
70
  [key: string]: unknown
65
71
  }
66
72
 
@@ -81,7 +87,3 @@ export interface PluginErrorOptions {
81
87
  details?: Record<string, any>
82
88
  response?: Response
83
89
  }
84
-
85
- export interface FatalPluginErrorOptions extends PluginErrorOptions {
86
- response: Response
87
- }
@@ -0,0 +1,159 @@
1
+ import { peerIdFromCID, peerIdFromString } from '@libp2p/peer-id'
2
+ import { CID } from 'multiformats/cid'
3
+ import { matchURLString } from './utils/parse-url-string.ts'
4
+ import type { ResolveURLOptions, ResolveURLResult, Resource, URLResolver as URLResolverInterface } from './index.ts'
5
+ import type { ServerTiming } from './utils/server-timing.ts'
6
+ import type { DNSLink } from '@helia/dnslink'
7
+ import type { IPNSResolver } from '@helia/ipns'
8
+ import type { AbortOptions, PeerId } from '@libp2p/interface'
9
+
10
+ const CODEC_LIBP2P_KEY = 0x72
11
+
12
+ export interface URLResolverComponents {
13
+ ipnsResolver: IPNSResolver
14
+ dnsLink: DNSLink
15
+ timing: ServerTiming
16
+ }
17
+
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
+ export class URLResolver implements URLResolverInterface {
42
+ private readonly components: URLResolverComponents
43
+
44
+ constructor (components: URLResolverComponents) {
45
+ this.components = components
46
+ }
47
+
48
+ async resolve (resource: Resource, options: ResolveURLOptions = {}): Promise<ResolveURLResult> {
49
+ if (typeof resource === 'string') {
50
+ return this.parseUrlString(resource, options)
51
+ }
52
+
53
+ const cid = CID.asCID(resource)
54
+
55
+ if (cid != null) {
56
+ return this.resolveCIDResource(cid, '', {}, options)
57
+ }
58
+
59
+ throw new TypeError(`Invalid resource. Cannot determine CID from resource: ${resource}`)
60
+ }
61
+
62
+ async parseUrlString (urlString: string, options: ResolveURLOptions = {}): Promise<ResolveURLResult> {
63
+ const { protocol, cidOrPeerIdOrDnsLink, path, query } = matchURLString(urlString)
64
+
65
+ if (protocol === 'ipfs') {
66
+ const cid = CID.parse(cidOrPeerIdOrDnsLink)
67
+
68
+ return this.resolveCIDResource(cid, path ?? '', toQuery(query), options)
69
+ }
70
+
71
+ if (protocol === 'ipns') {
72
+ // try to parse target as peer id
73
+ let peerId: PeerId
74
+
75
+ try {
76
+ peerId = peerIdFromString(cidOrPeerIdOrDnsLink)
77
+ } catch {
78
+ // fall back to DNSLink (e.g. /ipns/example.com)
79
+ return this.resolveDNSLink(cidOrPeerIdOrDnsLink, path ?? '', toQuery(query), options)
80
+ }
81
+
82
+ // parse multihash from string (e.g. /ipns/QmFoo...)
83
+ return this.resolveIPNSName(cidOrPeerIdOrDnsLink, peerId, path ?? '', toQuery(query), options)
84
+ }
85
+
86
+ throw new TypeError(`Invalid resource. Cannot determine CID from resource: ${urlString}`)
87
+ }
88
+
89
+ async resolveCIDResource (cid: CID, path: string, query: Record<string, any>, options: ResolveURLOptions = {}): Promise<ResolveURLResult> {
90
+ if (cid.code === CODEC_LIBP2P_KEY) {
91
+ // special case - peer id encoded as a CID
92
+ return this.resolveIPNSName(cid.toString(), peerIdFromCID(cid), path, query, options)
93
+ }
94
+
95
+ return {
96
+ cid,
97
+ protocol: 'ipfs',
98
+ query,
99
+ path,
100
+ ttl: 29030400, // 1 year for ipfs content
101
+ ipfsPath: `/ipfs/${cid}${path === '' ? '' : `/${path}`}`
102
+ }
103
+ }
104
+
105
+ async resolveDNSLink (domain: string, path: string, query: Record<string, any>, options?: ResolveURLOptions): Promise<ResolveURLResult> {
106
+ const results = await this.components.timing.time('dnsLink.resolve', `Resolve DNSLink ${domain}`, this.components.dnsLink.resolve(domain, options))
107
+ const result = results?.[0]
108
+
109
+ if (result == null) {
110
+ throw new TypeError(`Invalid resource. Cannot resolve DNSLink from domain: ${domain}`)
111
+ }
112
+
113
+ // dnslink resolved to IPNS name
114
+ if (result.namespace === 'ipns') {
115
+ return this.resolveIPNSName(domain, result.peerId, path, query, options)
116
+ }
117
+
118
+ // dnslink resolved to CID
119
+ if (result.namespace !== 'ipfs') {
120
+ // @ts-expect-error result namespace should only be ipns or ipfs
121
+ throw new TypeError(`Invalid resource. Unexpected DNSLink namespace ${result.namespace} from domain: ${domain}`)
122
+ }
123
+
124
+ return {
125
+ cid: result.cid,
126
+ path: concatPaths(result.path, path),
127
+ // dnslink is mutable so return 'ipns' protocol so we do not include immutable in cache-control header
128
+ protocol: 'ipns',
129
+ ttl: result.answer.TTL,
130
+ query,
131
+ ipfsPath: `/ipns/${domain}${path === '' ? '' : `/${path}`}`
132
+ }
133
+ }
134
+
135
+ async resolveIPNSName (resource: string, key: PeerId, path: string, query: Record<string, any>, options?: AbortOptions): Promise<ResolveURLResult> {
136
+ const result = await this.components.timing.time('ipns.resolve', `Resolve IPNS name ${key}`, this.components.ipnsResolver.resolve(key, options))
137
+
138
+ return {
139
+ cid: result.cid,
140
+ path: concatPaths(result.path, path),
141
+ query,
142
+ protocol: 'ipns',
143
+ // IPNS ttl is in nanoseconds, convert to seconds
144
+ ttl: Number((result.record.ttl ?? 0n) / BigInt(1e9)),
145
+ ipfsPath: `/ipns/${resource}${path === '' ? '' : `/${path}`}`
146
+ }
147
+ }
148
+ }
149
+
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
+ }`
159
+ }
@@ -2,8 +2,8 @@ import toBrowserReadableStream from 'it-to-browser-readablestream'
2
2
  import { InvalidRangeError } from '../errors.js'
3
3
  import { calculateByteRangeIndexes, getHeader } from './request-headers.js'
4
4
  import { getContentRangeHeader } from './response-headers.js'
5
- import type { SupportedBodyTypes } from '../types.js'
6
- import type { ComponentLogger, Logger } from '@libp2p/interface'
5
+ import type { SupportedBodyTypes } from '../index.js'
6
+ import type { Logger } from '@libp2p/interface'
7
7
 
8
8
  type SliceableBody = Exclude<SupportedBodyTypes, ReadableStream<Uint8Array> | null>
9
9
 
@@ -90,8 +90,8 @@ export class ByteRangeContext {
90
90
  // to be set by isValidRangeRequest so that we don't need to re-check the byteRanges
91
91
  private _isValidRangeRequest: boolean = false
92
92
 
93
- constructor (logger: ComponentLogger, private readonly headers?: HeadersInit) {
94
- this.log = logger.forComponent('helia:verified-fetch:byte-range-context')
93
+ constructor (logger: Logger, private readonly headers?: HeadersInit) {
94
+ this.log = logger.newScope('byte-range-context')
95
95
  this.rangeRequestHeader = getHeader(this.headers, 'Range')
96
96
 
97
97
  if (this.rangeRequestHeader != null) {
@@ -1,28 +1,22 @@
1
- import { logger } from '@libp2p/logger'
2
1
  import { fileTypeFromBuffer } from 'file-type'
3
2
 
4
- const log = logger('helia:verified-fetch:content-type-parser')
5
-
6
3
  export const defaultMimeType = 'application/octet-stream'
7
4
  function checkForSvg (text: string): boolean {
8
- log('checking for svg')
9
5
  return /^(<\?xml[^>]+>)?[^<^\w]+<svg/ig.test(text)
10
6
  }
11
7
 
12
8
  async function checkForJson (text: string): Promise<boolean> {
13
- log('checking for json')
14
9
  try {
15
10
  JSON.parse(text)
16
11
  return true
17
12
  } catch (err) {
18
- log('failed to parse as json', err)
19
13
  return false
20
14
  }
21
15
  }
22
16
 
23
17
  function getText (bytes: Uint8Array): string | null {
24
- log('checking for text')
25
18
  const decoder = new TextDecoder('utf-8', { fatal: true })
19
+
26
20
  try {
27
21
  return decoder.decode(bytes)
28
22
  } catch (err) {
@@ -31,25 +25,24 @@ function getText (bytes: Uint8Array): string | null {
31
25
  }
32
26
 
33
27
  async function checkForHtml (text: string): Promise<boolean> {
34
- log('checking for html')
35
28
  return /^\s*<(?:!doctype\s+html|html|head|body)\b/i.test(text)
36
29
  }
37
30
 
38
31
  export async function contentTypeParser (bytes: Uint8Array, fileName?: string): Promise<string> {
39
- log('contentTypeParser called for fileName: %s, byte size=%s', fileName, bytes.length)
40
32
  const detectedType = (await fileTypeFromBuffer(bytes))?.mime
33
+
41
34
  if (detectedType != null) {
42
- log('detectedType: %s', detectedType)
43
35
  if (detectedType === 'application/xml' && fileName?.toLowerCase().endsWith('.svg')) {
44
36
  return 'image/svg+xml'
45
37
  }
38
+
46
39
  return detectedType
47
40
  }
48
- log('no detectedType')
49
41
 
50
42
  if (fileName == null) {
51
43
  // it's likely text... no other way to determine file-type.
52
44
  const text = getText(bytes)
45
+
53
46
  if (text != null) {
54
47
  // check for svg, json, html, or it's plain text.
55
48
  if (checkForSvg(text)) {
@@ -62,6 +55,7 @@ export async function contentTypeParser (bytes: Uint8Array, fileName?: string):
62
55
  return 'text/plain; charset=utf-8'
63
56
  }
64
57
  }
58
+
65
59
  return defaultMimeType
66
60
  }
67
61
 
@@ -0,0 +1,22 @@
1
+ function isAggregateError (err?: any): err is AggregateError {
2
+ return err instanceof AggregateError || (err?.name === 'AggregateError' && Array.isArray(err?.errors))
3
+ }
4
+
5
+ /**
6
+ * Error instance properties are not enumerable so we must transform the error
7
+ * into a plain object if we want to pass it to `JSON.stringify` or similar.
8
+ */
9
+ export function errorToObject (err: Error): any {
10
+ let errors
11
+
12
+ if (isAggregateError(err)) {
13
+ errors = err.errors.map(err => errorToObject(err))
14
+ }
15
+
16
+ return {
17
+ name: err.name,
18
+ message: err.message,
19
+ stack: err.stack,
20
+ errors
21
+ }
22
+ }