@helia/verified-fetch 3.2.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +5 -5
  2. package/dist/index.min.js +81 -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 +57 -13
  9. package/dist/src/index.d.ts.map +1 -1
  10. package/dist/src/index.js +6 -6
  11. package/dist/src/index.js.map +1 -1
  12. package/dist/src/plugins/plugin-handle-car.d.ts.map +1 -1
  13. package/dist/src/plugins/plugin-handle-car.js +37 -27
  14. package/dist/src/plugins/plugin-handle-car.js.map +1 -1
  15. package/dist/src/plugins/plugin-handle-dag-cbor-html-preview.d.ts +1 -1
  16. package/dist/src/plugins/plugin-handle-dag-cbor-html-preview.d.ts.map +1 -1
  17. package/dist/src/plugins/plugin-handle-dag-cbor-html-preview.js +1 -1
  18. package/dist/src/plugins/plugin-handle-dag-cbor-html-preview.js.map +1 -1
  19. package/dist/src/plugins/plugin-handle-dag-cbor.js +5 -5
  20. package/dist/src/plugins/plugin-handle-dag-cbor.js.map +1 -1
  21. package/dist/src/plugins/plugin-handle-dag-pb.js +12 -12
  22. package/dist/src/plugins/plugin-handle-dag-pb.js.map +1 -1
  23. package/dist/src/plugins/plugin-handle-dag-walk.d.ts.map +1 -1
  24. package/dist/src/plugins/plugin-handle-dag-walk.js +5 -4
  25. package/dist/src/plugins/plugin-handle-dag-walk.js.map +1 -1
  26. package/dist/src/plugins/plugin-handle-ipns-record.d.ts.map +1 -1
  27. package/dist/src/plugins/plugin-handle-ipns-record.js +13 -19
  28. package/dist/src/plugins/plugin-handle-ipns-record.js.map +1 -1
  29. package/dist/src/plugins/plugin-handle-json.d.ts.map +1 -1
  30. package/dist/src/plugins/plugin-handle-json.js +5 -4
  31. package/dist/src/plugins/plugin-handle-json.js.map +1 -1
  32. package/dist/src/plugins/plugin-handle-raw.d.ts.map +1 -1
  33. package/dist/src/plugins/plugin-handle-raw.js +18 -5
  34. package/dist/src/plugins/plugin-handle-raw.js.map +1 -1
  35. package/dist/src/plugins/plugin-handle-tar.js +1 -1
  36. package/dist/src/plugins/plugin-handle-tar.js.map +1 -1
  37. package/dist/src/plugins/types.d.ts +10 -8
  38. package/dist/src/plugins/types.d.ts.map +1 -1
  39. package/dist/src/url-resolver.d.ts +21 -0
  40. package/dist/src/url-resolver.d.ts.map +1 -0
  41. package/dist/src/url-resolver.js +118 -0
  42. package/dist/src/url-resolver.js.map +1 -0
  43. package/dist/src/utils/byte-range-context.d.ts +1 -1
  44. package/dist/src/utils/content-type-parser.d.ts.map +1 -1
  45. package/dist/src/utils/content-type-parser.js +3 -0
  46. package/dist/src/utils/content-type-parser.js.map +1 -1
  47. package/dist/src/utils/get-content-type.d.ts +3 -3
  48. package/dist/src/utils/get-content-type.d.ts.map +1 -1
  49. package/dist/src/utils/get-content-type.js +1 -1
  50. package/dist/src/utils/get-content-type.js.map +1 -1
  51. package/dist/src/utils/get-e-tag.d.ts +1 -1
  52. package/dist/src/utils/get-offset-and-length.d.ts +6 -0
  53. package/dist/src/utils/get-offset-and-length.d.ts.map +1 -0
  54. package/dist/src/utils/get-offset-and-length.js +46 -0
  55. package/dist/src/utils/get-offset-and-length.js.map +1 -0
  56. package/dist/src/utils/get-resolved-accept-header.d.ts +2 -2
  57. package/dist/src/utils/get-resolved-accept-header.d.ts.map +1 -1
  58. package/dist/src/utils/handle-redirects.d.ts.map +1 -1
  59. package/dist/src/utils/handle-redirects.js +3 -3
  60. package/dist/src/utils/handle-redirects.js.map +1 -1
  61. package/dist/src/utils/ipfs-path-to-string.d.ts +6 -0
  62. package/dist/src/utils/ipfs-path-to-string.d.ts.map +1 -0
  63. package/dist/src/utils/ipfs-path-to-string.js +10 -0
  64. package/dist/src/utils/ipfs-path-to-string.js.map +1 -0
  65. package/dist/src/utils/is-accept-explicit.d.ts +6 -4
  66. package/dist/src/utils/is-accept-explicit.d.ts.map +1 -1
  67. package/dist/src/utils/is-accept-explicit.js +7 -4
  68. package/dist/src/utils/is-accept-explicit.js.map +1 -1
  69. package/dist/src/utils/parse-url-string.d.ts +1 -55
  70. package/dist/src/utils/parse-url-string.d.ts.map +1 -1
  71. package/dist/src/utils/parse-url-string.js +16 -217
  72. package/dist/src/utils/parse-url-string.js.map +1 -1
  73. package/dist/src/utils/response-headers.d.ts +1 -1
  74. package/dist/src/utils/response-headers.d.ts.map +1 -1
  75. package/dist/src/utils/responses.d.ts +1 -1
  76. package/dist/src/utils/select-output-type.d.ts +6 -2
  77. package/dist/src/utils/select-output-type.d.ts.map +1 -1
  78. package/dist/src/utils/select-output-type.js +28 -37
  79. package/dist/src/utils/select-output-type.js.map +1 -1
  80. package/dist/src/utils/server-timing.d.ts +5 -11
  81. package/dist/src/utils/server-timing.d.ts.map +1 -1
  82. package/dist/src/utils/server-timing.js +17 -15
  83. package/dist/src/utils/server-timing.js.map +1 -1
  84. package/dist/src/utils/walk-path.js +1 -1
  85. package/dist/src/utils/walk-path.js.map +1 -1
  86. package/dist/src/verified-fetch.d.ts +3 -10
  87. package/dist/src/verified-fetch.d.ts.map +1 -1
  88. package/dist/src/verified-fetch.js +68 -57
  89. package/dist/src/verified-fetch.js.map +1 -1
  90. package/dist/typedoc-urls.json +13 -2
  91. package/package.json +35 -36
  92. package/src/constants.ts +1 -0
  93. package/src/index.ts +73 -22
  94. package/src/plugins/plugin-handle-car.ts +54 -30
  95. package/src/plugins/plugin-handle-dag-cbor-html-preview.ts +2 -2
  96. package/src/plugins/plugin-handle-dag-cbor.ts +5 -5
  97. package/src/plugins/plugin-handle-dag-pb.ts +12 -12
  98. package/src/plugins/plugin-handle-dag-walk.ts +5 -4
  99. package/src/plugins/plugin-handle-ipns-record.ts +16 -19
  100. package/src/plugins/plugin-handle-json.ts +5 -4
  101. package/src/plugins/plugin-handle-raw.ts +21 -6
  102. package/src/plugins/plugin-handle-tar.ts +1 -1
  103. package/src/plugins/types.ts +12 -8
  104. package/src/url-resolver.ts +159 -0
  105. package/src/utils/byte-range-context.ts +1 -1
  106. package/src/utils/content-type-parser.ts +3 -0
  107. package/src/utils/get-content-type.ts +5 -4
  108. package/src/utils/get-e-tag.ts +1 -1
  109. package/src/utils/get-offset-and-length.ts +54 -0
  110. package/src/utils/get-resolved-accept-header.ts +2 -2
  111. package/src/utils/handle-redirects.ts +10 -3
  112. package/src/utils/ipfs-path-to-string.ts +9 -0
  113. package/src/utils/is-accept-explicit.ts +14 -7
  114. package/src/utils/parse-url-string.ts +20 -286
  115. package/src/utils/response-headers.ts +1 -1
  116. package/src/utils/responses.ts +1 -1
  117. package/src/utils/select-output-type.ts +38 -44
  118. package/src/utils/server-timing.ts +17 -30
  119. package/src/utils/walk-path.ts +1 -1
  120. package/src/verified-fetch.ts +78 -69
  121. package/dist/src/types.d.ts +0 -16
  122. package/dist/src/types.d.ts.map +0 -1
  123. package/dist/src/types.js +0 -2
  124. package/dist/src/types.js.map +0 -1
  125. package/dist/src/utils/parse-resource.d.ts +0 -18
  126. package/dist/src/utils/parse-resource.d.ts.map +0 -1
  127. package/dist/src/utils/parse-resource.js +0 -27
  128. package/dist/src/utils/parse-resource.js.map +0 -1
  129. package/src/types.ts +0 -17
  130. package/src/utils/parse-resource.ts +0 -42
@@ -55,8 +55,8 @@ export class DagPbPlugin extends BasePlugin {
55
55
  }
56
56
 
57
57
  async handle (context: PluginContext & Required<Pick<PluginContext, 'byteRangeContext' | 'pathDetails'>>): Promise<Response | null> {
58
- const { cid, options, withServerTiming = false, pathDetails, query } = context
59
- const { handleServerTiming, contentTypeParser, helia, getBlockstore } = this.pluginOptions
58
+ const { cid, options, pathDetails, query } = context
59
+ const { contentTypeParser, helia, getBlockstore } = this.pluginOptions
60
60
  const log = this.log
61
61
  let resource = context.resource
62
62
  let path = context.path
@@ -93,10 +93,10 @@ export class DagPbPlugin extends BasePlugin {
93
93
  try {
94
94
  log.trace('found directory at %c/%s, looking for index.html', cid, path)
95
95
 
96
- const entry = await handleServerTiming('exporter-dir', '', async () => exporter(`/ipfs/${dirCid}/${rootFilePath}`, helia.blockstore, {
96
+ const entry = await context.serverTiming.time('exporter-dir', '', exporter(`/ipfs/${dirCid}/${rootFilePath}`, helia.blockstore, {
97
97
  signal: options?.signal,
98
98
  onProgress: options?.onProgress
99
- }), withServerTiming)
99
+ }))
100
100
 
101
101
  log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, entry.cid)
102
102
  path = rootFilePath
@@ -136,10 +136,10 @@ export class DagPbPlugin extends BasePlugin {
136
136
  }
137
137
 
138
138
  try {
139
- const entry = await handleServerTiming('exporter-file', '', async () => exporter(resolvedCID, helia.blockstore, {
139
+ const entry = await context.serverTiming.time('exporter-file', '', exporter(resolvedCID, helia.blockstore, {
140
140
  signal: options?.signal,
141
141
  onProgress: options?.onProgress
142
- }), withServerTiming)
142
+ }))
143
143
 
144
144
  let firstChunk: Uint8Array
145
145
  let contentType: string
@@ -152,13 +152,13 @@ export class DagPbPlugin extends BasePlugin {
152
152
  })
153
153
  log('got async iterator for %c/%s', cid, path)
154
154
 
155
- const streamAndFirstChunk = await handleServerTiming('stream-and-chunk', '', async () => getStreamFromAsyncIterable(asyncIter, path ?? '', this.pluginOptions.logger, {
155
+ const streamAndFirstChunk = await context.serverTiming.time('stream-and-chunk', '', getStreamFromAsyncIterable(asyncIter, path ?? '', this.pluginOptions.logger, {
156
156
  onProgress: options?.onProgress,
157
157
  signal: options?.signal
158
- }), withServerTiming)
158
+ }))
159
159
  const stream = streamAndFirstChunk.stream
160
160
  firstChunk = streamAndFirstChunk.firstChunk
161
- contentType = await handleServerTiming('get-content-type', '', async () => getContentType({ filename: query.filename, bytes: firstChunk, path, contentTypeParser, log }), withServerTiming)
161
+ contentType = await context.serverTiming.time('get-content-type', '', getContentType({ filename: query.filename, bytes: firstChunk, path, contentTypeParser, log }))
162
162
 
163
163
  byteRangeContext.setBody(stream)
164
164
  }
@@ -186,8 +186,8 @@ export class DagPbPlugin extends BasePlugin {
186
186
  }
187
187
 
188
188
  private async handleRangeRequest (context: PluginContext & Required<Pick<PluginContext, 'byteRangeContext' | 'pathDetails'>>, entry: UnixFSEntry): Promise<string> {
189
- const { path, byteRangeContext, options, withServerTiming = false } = context
190
- const { handleServerTiming, contentTypeParser } = this.pluginOptions
189
+ const { path, byteRangeContext, options } = context
190
+ const { contentTypeParser } = this.pluginOptions
191
191
  const log = this.log
192
192
 
193
193
  // get the first chunk in order to determine the content type
@@ -203,7 +203,7 @@ export class DagPbPlugin extends BasePlugin {
203
203
  onProgress: options?.onProgress,
204
204
  signal: options?.signal
205
205
  })
206
- const contentType = await handleServerTiming('get-content-type', '', async () => getContentType({ bytes: firstChunk, path, contentTypeParser, log }), withServerTiming)
206
+ const contentType = await context.serverTiming.time('get-content-type', '', getContentType({ bytes: firstChunk, path, contentTypeParser, log }))
207
207
 
208
208
  byteRangeContext?.setBody((range): AsyncGenerator<Uint8Array, void, unknown> => {
209
209
  if (options?.signal?.aborted) {
@@ -1,5 +1,6 @@
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'
@@ -23,16 +24,16 @@ export class DagWalkPlugin extends BasePlugin {
23
24
  return false
24
25
  }
25
26
 
26
- return (cid.code === dagPbCode || cid.code === dagCborCode)
27
+ return (cid.code === dagPbCode || cid.code === dagCborCode || cid.multihash.code === CODEC_IDENTITY)
27
28
  }
28
29
 
29
30
  async handle (context: PluginContext): Promise<Response | null> {
30
- const { cid, resource, options, withServerTiming = false } = context
31
- const { getBlockstore, handleServerTiming } = this.pluginOptions
31
+ const { cid, resource, options } = context
32
+ const { getBlockstore } = this.pluginOptions
32
33
  const blockstore = getBlockstore(cid, resource, options?.session ?? true, options)
33
34
 
34
35
  // TODO: migrate handlePathWalking into this plugin
35
- const pathDetails = await handleServerTiming('path-walking', '', async () => handlePathWalking({ ...context, blockstore, log: this.log }), withServerTiming)
36
+ const pathDetails = await context.serverTiming.time('path-walking', '', handlePathWalking({ ...context, blockstore, log: this.log }))
36
37
 
37
38
  if (pathDetails instanceof Response) {
38
39
  this.log.trace('path walking failed')
@@ -1,8 +1,4 @@
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
4
  import { PluginFatalError } from './errors.js'
@@ -23,17 +19,19 @@ export class IpnsRecordPlugin extends BasePlugin {
23
19
  return false
24
20
  }
25
21
 
26
- return accept === 'application/vnd.ipfs.ipns-record' || query.format === 'ipns-record'
22
+ return accept?.mimeType === 'application/vnd.ipfs.ipns-record' || query.format === 'ipns-record'
27
23
  }
28
24
 
29
25
  async handle (context: PluginContext & Required<Pick<PluginContext, 'byteRangeContext'>>): Promise<Response> {
30
- const { resource, path, options } = context
31
- const { helia } = this.pluginOptions
26
+ const { resource, path, query, options } = context
27
+ const { ipnsResolver } = this.pluginOptions
32
28
  context.reqFormat = 'ipns-record'
29
+
33
30
  if (path !== '' || !(resource.startsWith('ipns://') || resource.includes('.ipns.') || resource.includes('/ipns/'))) {
34
31
  this.log.error('invalid request for IPNS name "%s" and path "%s"', resource, path)
35
32
  throw new PluginFatalError('ERR_INVALID_IPNS_NAME', 'Invalid IPNS name', { response: badRequestResponse(resource, new Error('Invalid IPNS name')) })
36
33
  }
34
+
37
35
  let peerId: PeerId
38
36
 
39
37
  try {
@@ -54,21 +52,20 @@ export class IpnsRecordPlugin extends BasePlugin {
54
52
  throw new PluginFatalError('ERR_NO_PEER_ID_FOUND', 'could not parse peer id from url', { response: 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'
@@ -17,7 +18,7 @@ export class JsonPlugin extends BasePlugin {
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,3 +1,4 @@
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'
@@ -5,6 +6,7 @@ import { notFoundResponse, okRangeResponse } from '../utils/responses.js'
5
6
  import { PluginFatalError } from './errors.js'
6
7
  import { BasePlugin } from './plugin-base.js'
7
8
  import type { PluginContext } from './types.js'
9
+ import type { AcceptHeader } from '../utils/select-output-type.ts'
8
10
 
9
11
  /**
10
12
  * These are Accept header values that will cause content type sniffing to be
@@ -22,9 +24,9 @@ const RAW_HEADERS = [
22
24
  * type. This avoids the user from receiving something different when they
23
25
  * signal that they want to `Accept` a specific mime type.
24
26
  */
25
- function getOverriddenRawContentType ({ headers, accept }: { headers?: HeadersInit, accept?: string }): string | undefined {
27
+ function getOverriddenRawContentType ({ headers, accept }: { headers?: HeadersInit, accept?: AcceptHeader }): string | undefined {
26
28
  // accept has already been resolved by getResolvedAcceptHeader, if we have it, use it.
27
- const acceptHeader = accept ?? new Headers(headers).get('accept') ?? ''
29
+ const acceptHeader = accept?.mimeType ?? new Headers(headers).get('accept') ?? ''
28
30
 
29
31
  // e.g. "Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8"
30
32
  const acceptHeaders = acceptHeader.split(',')
@@ -51,7 +53,7 @@ export class RawPlugin extends BasePlugin {
51
53
  if (byteRangeContext == null) {
52
54
  return false
53
55
  }
54
- return accept === 'application/vnd.ipld.raw' || query.format === 'raw'
56
+ return accept?.mimeType === 'application/vnd.ipld.raw' || query.format === 'raw'
55
57
  }
56
58
 
57
59
  async handle (context: PluginContext & Required<Pick<PluginContext, 'byteRangeContext'>>): Promise<Response> {
@@ -60,7 +62,7 @@ export class RawPlugin extends BasePlugin {
60
62
  const session = options?.session ?? true
61
63
  const log = this.log
62
64
 
63
- if (accept === 'application/vnd.ipld.raw' || query.format === 'raw') {
65
+ if (accept?.mimeType === 'application/vnd.ipld.raw' || query.format === 'raw') {
64
66
  context.reqFormat = 'raw'
65
67
  context.query.download = true
66
68
  context.query.filename = context.query.filename ?? `${cid.toString()}.bin`
@@ -78,18 +80,31 @@ export class RawPlugin extends BasePlugin {
78
80
 
79
81
  const terminalCid = context.pathDetails?.terminalElement.cid ?? context.cid
80
82
  const blockstore = getBlockstore(terminalCid, resource, session, options)
81
- const result = await blockstore.get(terminalCid, options)
83
+ const result = await toBuffer(blockstore.get(terminalCid, options))
82
84
  context.byteRangeContext.setBody(result)
83
85
 
84
86
  // if the user has specified an `Accept` header that corresponds to a raw
85
87
  // type, honour that header, so for example they don't request
86
88
  // `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 })
89
+ const contentType = await getContentType({
90
+ filename: query.filename,
91
+ bytes: result,
92
+ path,
93
+ defaultContentType: getOverriddenRawContentType({ headers: options?.headers, accept }),
94
+ contentTypeParser,
95
+ log
96
+ })
88
97
  const response = okRangeResponse(resource, context.byteRangeContext.getBody(contentType), { byteRangeContext: context.byteRangeContext, log }, {
89
98
  redirected: false
90
99
  })
91
100
 
92
101
  response.headers.set('content-type', context.byteRangeContext.getContentType() ?? contentType)
102
+ response.headers.set('x-ipfs-roots', terminalCid.toV1().toString())
103
+
104
+ // only set content-length if it is not a range request
105
+ if (!context.byteRangeContext.isRangeRequest) {
106
+ response.headers.set('content-length', result.byteLength.toString())
107
+ }
93
108
 
94
109
  return response
95
110
  }
@@ -19,7 +19,7 @@ export class TarPlugin extends BasePlugin {
19
19
  if (byteRangeContext == null) {
20
20
  return false
21
21
  }
22
- return accept === 'application/x-tar' || query.format === 'tar'
22
+ return accept?.mimeType === 'application/x-tar' || query.format === 'tar'
23
23
  }
24
24
 
25
25
  async handle (context: PluginContext & Required<Pick<PluginContext, 'byteRangeContext'>>): Promise<Response> {
@@ -1,9 +1,10 @@
1
1
  import type { PluginError } from './errors.js'
2
- import type { VerifiedFetchInit } from '../index.js'
3
- import type { ContentTypeParser, RequestFormatShorthand } from '../types.js'
2
+ import type { ResolveURLResult, UrlQuery, VerifiedFetchInit, ContentTypeParser, RequestFormatShorthand } from '../index.js'
4
3
  import type { ByteRangeContext } from '../utils/byte-range-context.js'
5
- import type { ParsedUrlStringResults } from '../utils/parse-url-string.js'
4
+ import type { AcceptHeader } from '../utils/select-output-type.ts'
5
+ import type { ServerTiming } from '../utils/server-timing.ts'
6
6
  import type { PathWalkerResponse } from '../utils/walk-path.js'
7
+ import type { IPNSResolver } from '@helia/ipns'
7
8
  import type { AbortOptions, ComponentLogger, Logger } from '@libp2p/interface'
8
9
  import type { Helia } from 'helia'
9
10
  import type { Blockstore } from 'interface-blockstore'
@@ -19,9 +20,9 @@ import type { CustomProgressEvent } from 'progress-events'
19
20
  export interface PluginOptions {
20
21
  logger: ComponentLogger
21
22
  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
23
  contentTypeParser?: ContentTypeParser
24
24
  helia: Helia
25
+ ipnsResolver: IPNSResolver
25
26
  }
26
27
 
27
28
  /**
@@ -30,22 +31,22 @@ export interface PluginOptions {
30
31
  * - Shared Data: Allows plugins to communicate partial results, discovered data, or interim errors.
31
32
  * - Ephemeral: Typically discarded once fetch(...) completes.
32
33
  */
33
- export interface PluginContext extends ParsedUrlStringResults {
34
+ export interface PluginContext extends ResolveURLResult {
34
35
  readonly cid: CID
35
36
  readonly path: string
36
37
  readonly resource: string
37
- readonly accept?: string
38
+ readonly accept?: AcceptHeader
38
39
 
39
40
  /**
40
41
  * An array of plugin IDs that are all enabled. You can use this to check if a plugin is enabled and respond accordingly.
41
42
  */
42
43
  plugins: string[]
44
+
43
45
  /**
44
46
  * The last time the context is modified, so we know whether a plugin has modified it.
45
47
  * A plugin should increment this value if it modifies the context.
46
48
  */
47
49
  modified: number
48
- withServerTiming?: boolean
49
50
  onProgress?(evt: CustomProgressEvent<any>): void
50
51
  options?: Omit<VerifiedFetchInit, 'signal'> & AbortOptions
51
52
  isDirectory?: boolean
@@ -53,7 +54,8 @@ export interface PluginContext extends ParsedUrlStringResults {
53
54
  errors?: PluginError[]
54
55
  reqFormat?: RequestFormatShorthand
55
56
  pathDetails?: PathWalkerResponse
56
- query: ParsedUrlStringResults['query']
57
+ query: UrlQuery
58
+
57
59
  /**
58
60
  * ByteRangeContext contains information about the size of the content and range requests.
59
61
  * This can be used to set the Content-Length header without loading the entire body.
@@ -61,6 +63,8 @@ export interface PluginContext extends ParsedUrlStringResults {
61
63
  * This is set by the ByteRangeContextPlugin
62
64
  */
63
65
  byteRangeContext?: ByteRangeContext
66
+ serverTiming: ServerTiming
67
+ ipfsPath: string
64
68
  [key: string]: unknown
65
69
  }
66
70
 
@@ -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,7 +2,7 @@ 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'
5
+ import type { SupportedBodyTypes } from '../index.js'
6
6
  import type { ComponentLogger, Logger } from '@libp2p/interface'
7
7
 
8
8
  type SliceableBody = Exclude<SupportedBodyTypes, ReadableStream<Uint8Array> | null>
@@ -40,6 +40,9 @@ export async function contentTypeParser (bytes: Uint8Array, fileName?: string):
40
40
  const detectedType = (await fileTypeFromBuffer(bytes))?.mime
41
41
  if (detectedType != null) {
42
42
  log('detectedType: %s', detectedType)
43
+ if (detectedType === 'application/xml' && fileName?.toLowerCase().endsWith('.svg')) {
44
+ return 'image/svg+xml'
45
+ }
43
46
  return detectedType
44
47
  }
45
48
  log('no detectedType')
@@ -1,13 +1,13 @@
1
1
  import { defaultMimeType } from './content-type-parser.js'
2
2
  import { isPromise } from './type-guards.js'
3
- import type { ContentTypeParser } from '../types.js'
3
+ import type { ContentTypeParser } from '../index.js'
4
4
  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
- contentTypeParser: ContentTypeParser | undefined
10
+ contentTypeParser?: ContentTypeParser
11
11
  log: Logger
12
12
 
13
13
  /**
@@ -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?.split('/').pop()?.trim()
29
29
  fileName = (fileName === '' || fileName?.split('.').length === 1) ? undefined : fileName
30
30
  } else {
31
31
  fileName = filenameParam
@@ -46,6 +46,7 @@ export async function getContentType ({ bytes, path, contentTypeParser, log, def
46
46
  log.error('error parsing content type', err)
47
47
  }
48
48
  }
49
+
49
50
  if (contentType === defaultMimeType) {
50
51
  // if the content type is the default in our content-type-parser, instead, set it to the default content type provided to this function.
51
52
  contentType = defaultContentType
@@ -1,4 +1,4 @@
1
- import type { RequestFormatShorthand } from '../types.js'
1
+ import type { RequestFormatShorthand } from '../index.js'
2
2
  import type { CID } from 'multiformats/cid'
3
3
 
4
4
  interface GetETagArg {
@@ -0,0 +1,54 @@
1
+ import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
2
+
3
+ export function getOffsetAndLength (entry: UnixFSEntry, entityBytes?: string): { offset: number, length: number } {
4
+ if (entityBytes == null) {
5
+ return {
6
+ offset: 0,
7
+ length: Infinity
8
+ }
9
+ }
10
+
11
+ const parts = entityBytes.split(':')
12
+ const start = parseInt(parts[0], 10)
13
+ const end = parts[1] === '*' ? Infinity : parseInt(parts[1], 10)
14
+
15
+ if (isNaN(start) || isNaN(end)) {
16
+ throw new Error('Could not parse entity-bytes')
17
+ }
18
+
19
+ const entrySize = Number(entry.size)
20
+
21
+ if (start >= 0) {
22
+ if (end >= 0) {
23
+ return {
24
+ offset: start,
25
+ length: end - start
26
+ }
27
+ } else {
28
+ return {
29
+ offset: start,
30
+ length: (entrySize - start) + end
31
+ }
32
+ }
33
+ }
34
+
35
+ // start < 0
36
+ let offset = entrySize + start
37
+
38
+ if (Math.abs(start) > entrySize) {
39
+ offset = 0
40
+ }
41
+
42
+ if (end >= 0) {
43
+ return {
44
+ offset,
45
+ length: (entrySize - offset) + end
46
+ }
47
+ }
48
+
49
+ // end < 0
50
+ return {
51
+ offset,
52
+ length: (entrySize - offset) + end
53
+ }
54
+ }
@@ -1,10 +1,10 @@
1
1
  import { isExplicitAcceptHeader, isExplicitFormatQuery, isExplicitIpldAcceptRequest } from './is-accept-explicit.js'
2
2
  import { queryFormatToAcceptHeader } from './select-output-type.js'
3
- import type { ParsedUrlStringResults } from './parse-url-string.js'
3
+ import type { UrlQuery } from '../index.ts'
4
4
  import type { ComponentLogger } from '@libp2p/interface'
5
5
 
6
6
  export interface ResolvedAcceptHeaderOptions {
7
- query?: ParsedUrlStringResults['query']
7
+ query?: UrlQuery
8
8
  headers?: RequestInit['headers']
9
9
  logger: ComponentLogger
10
10
  }