@helia/verified-fetch 2.3.1 → 2.5.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 (119) hide show
  1. package/README.md +200 -0
  2. package/dist/index.min.js +357 -35
  3. package/dist/src/index.d.ts +220 -0
  4. package/dist/src/index.d.ts.map +1 -1
  5. package/dist/src/index.js +200 -0
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/plugins/errors.d.ts +25 -0
  8. package/dist/src/plugins/errors.d.ts.map +1 -0
  9. package/dist/src/plugins/errors.js +33 -0
  10. package/dist/src/plugins/errors.js.map +1 -0
  11. package/dist/src/plugins/index.d.ts +8 -0
  12. package/dist/src/plugins/index.d.ts.map +1 -0
  13. package/dist/src/plugins/index.js +7 -0
  14. package/dist/src/plugins/index.js.map +1 -0
  15. package/dist/src/plugins/plugin-base.d.ts +19 -0
  16. package/dist/src/plugins/plugin-base.d.ts.map +1 -0
  17. package/dist/src/plugins/plugin-base.js +26 -0
  18. package/dist/src/plugins/plugin-base.js.map +1 -0
  19. package/dist/src/plugins/plugin-handle-car.d.ts +11 -0
  20. package/dist/src/plugins/plugin-handle-car.d.ts.map +1 -0
  21. package/dist/src/plugins/plugin-handle-car.js +28 -0
  22. package/dist/src/plugins/plugin-handle-car.js.map +1 -0
  23. package/dist/src/plugins/plugin-handle-dag-cbor.d.ts +11 -0
  24. package/dist/src/plugins/plugin-handle-dag-cbor.d.ts.map +1 -0
  25. package/dist/src/plugins/plugin-handle-dag-cbor.js +73 -0
  26. package/dist/src/plugins/plugin-handle-dag-cbor.js.map +1 -0
  27. package/dist/src/plugins/plugin-handle-dag-pb.d.ts +15 -0
  28. package/dist/src/plugins/plugin-handle-dag-pb.d.ts.map +1 -0
  29. package/dist/src/plugins/plugin-handle-dag-pb.js +152 -0
  30. package/dist/src/plugins/plugin-handle-dag-pb.js.map +1 -0
  31. package/dist/src/plugins/plugin-handle-dag-walk.d.ts +16 -0
  32. package/dist/src/plugins/plugin-handle-dag-walk.d.ts.map +1 -0
  33. package/dist/src/plugins/plugin-handle-dag-walk.js +45 -0
  34. package/dist/src/plugins/plugin-handle-dag-walk.js.map +1 -0
  35. package/dist/src/plugins/plugin-handle-dir-index-html.d.ts +9 -0
  36. package/dist/src/plugins/plugin-handle-dir-index-html.d.ts.map +1 -0
  37. package/dist/src/plugins/plugin-handle-dir-index-html.js +37 -0
  38. package/dist/src/plugins/plugin-handle-dir-index-html.js.map +1 -0
  39. package/dist/src/plugins/plugin-handle-ipns-record.d.ts +12 -0
  40. package/dist/src/plugins/plugin-handle-ipns-record.d.ts.map +1 -0
  41. package/dist/src/plugins/plugin-handle-ipns-record.js +62 -0
  42. package/dist/src/plugins/plugin-handle-ipns-record.js.map +1 -0
  43. package/dist/src/plugins/plugin-handle-json.d.ts +11 -0
  44. package/dist/src/plugins/plugin-handle-json.d.ts.map +1 -0
  45. package/dist/src/plugins/plugin-handle-json.js +51 -0
  46. package/dist/src/plugins/plugin-handle-json.js.map +1 -0
  47. package/dist/src/plugins/plugin-handle-raw.d.ts +8 -0
  48. package/dist/src/plugins/plugin-handle-raw.d.ts.map +1 -0
  49. package/dist/src/plugins/plugin-handle-raw.js +80 -0
  50. package/dist/src/plugins/plugin-handle-raw.js.map +1 -0
  51. package/dist/src/plugins/plugin-handle-tar.d.ts +12 -0
  52. package/dist/src/plugins/plugin-handle-tar.d.ts.map +1 -0
  53. package/dist/src/plugins/plugin-handle-tar.js +36 -0
  54. package/dist/src/plugins/plugin-handle-tar.js.map +1 -0
  55. package/dist/src/plugins/plugins.d.ts +5 -0
  56. package/dist/src/plugins/plugins.d.ts.map +1 -0
  57. package/dist/src/plugins/plugins.js +5 -0
  58. package/dist/src/plugins/plugins.js.map +1 -0
  59. package/dist/src/plugins/types.d.ts +68 -0
  60. package/dist/src/plugins/types.d.ts.map +1 -0
  61. package/dist/src/plugins/types.js +2 -0
  62. package/dist/src/plugins/types.js.map +1 -0
  63. package/dist/src/types.d.ts +0 -23
  64. package/dist/src/types.d.ts.map +1 -1
  65. package/dist/src/types.js +1 -2
  66. package/dist/src/types.js.map +1 -1
  67. package/dist/src/utils/dir-index-html.d.ts +16 -0
  68. package/dist/src/utils/dir-index-html.d.ts.map +1 -0
  69. package/dist/src/utils/dir-index-html.js +387 -0
  70. package/dist/src/utils/dir-index-html.js.map +1 -0
  71. package/dist/src/utils/get-e-tag.d.ts +1 -1
  72. package/dist/src/utils/get-e-tag.d.ts.map +1 -1
  73. package/dist/src/utils/get-e-tag.js +18 -3
  74. package/dist/src/utils/get-e-tag.js.map +1 -1
  75. package/dist/src/utils/parse-resource.d.ts +2 -1
  76. package/dist/src/utils/parse-resource.d.ts.map +1 -1
  77. package/dist/src/utils/parse-resource.js +4 -3
  78. package/dist/src/utils/parse-resource.js.map +1 -1
  79. package/dist/src/utils/parse-url-string.d.ts +8 -3
  80. package/dist/src/utils/parse-url-string.d.ts.map +1 -1
  81. package/dist/src/utils/parse-url-string.js +30 -4
  82. package/dist/src/utils/parse-url-string.js.map +1 -1
  83. package/dist/src/utils/server-timing.d.ts +13 -0
  84. package/dist/src/utils/server-timing.d.ts.map +1 -0
  85. package/dist/src/utils/server-timing.js +19 -0
  86. package/dist/src/utils/server-timing.js.map +1 -0
  87. package/dist/src/utils/walk-path.d.ts +3 -2
  88. package/dist/src/utils/walk-path.d.ts.map +1 -1
  89. package/dist/src/utils/walk-path.js +1 -1
  90. package/dist/src/utils/walk-path.js.map +1 -1
  91. package/dist/src/verified-fetch.d.ts +11 -20
  92. package/dist/src/verified-fetch.d.ts.map +1 -1
  93. package/dist/src/verified-fetch.js +174 -367
  94. package/dist/src/verified-fetch.js.map +1 -1
  95. package/dist/typedoc-urls.json +32 -24
  96. package/package.json +6 -2
  97. package/src/index.ts +223 -0
  98. package/src/plugins/errors.ts +37 -0
  99. package/src/plugins/index.ts +8 -0
  100. package/src/plugins/plugin-base.ts +30 -0
  101. package/src/plugins/plugin-handle-car.ts +32 -0
  102. package/src/plugins/plugin-handle-dag-cbor.ts +84 -0
  103. package/src/plugins/plugin-handle-dag-pb.ts +168 -0
  104. package/src/plugins/plugin-handle-dag-walk.ts +53 -0
  105. package/src/plugins/plugin-handle-dir-index-html.ts +44 -0
  106. package/src/plugins/plugin-handle-ipns-record.ts +69 -0
  107. package/src/plugins/plugin-handle-json.ts +57 -0
  108. package/src/plugins/plugin-handle-raw.ts +92 -0
  109. package/src/plugins/plugin-handle-tar.ts +44 -0
  110. package/src/plugins/plugins.ts +4 -0
  111. package/src/plugins/types.ts +73 -0
  112. package/src/types.ts +0 -29
  113. package/src/utils/dir-index-html.ts +445 -0
  114. package/src/utils/get-e-tag.ts +20 -3
  115. package/src/utils/parse-resource.ts +5 -4
  116. package/src/utils/parse-url-string.ts +38 -7
  117. package/src/utils/server-timing.ts +37 -0
  118. package/src/utils/walk-path.ts +3 -3
  119. package/src/verified-fetch.ts +198 -403
@@ -1,41 +1,31 @@
1
- import { car } from '@helia/car'
2
1
  import { ipns as heliaIpns, type IPNS } from '@helia/ipns'
3
- import * as ipldDagCbor from '@ipld/dag-cbor'
4
- import * as ipldDagJson from '@ipld/dag-json'
5
- import { code as dagPbCode } from '@ipld/dag-pb'
6
- import { type AbortOptions, type Logger, type PeerId } from '@libp2p/interface'
7
- import { Record as DHTRecord } from '@libp2p/kad-dht'
8
- import { Key } from 'interface-datastore'
9
- import { exporter, type ObjectNode } from 'ipfs-unixfs-exporter'
10
- import toBrowserReadableStream from 'it-to-browser-readablestream'
2
+ import { type AbortOptions, type Logger } from '@libp2p/interface'
3
+ import { prefixLogger } from '@libp2p/logger'
11
4
  import { LRUCache } from 'lru-cache'
12
5
  import { type CID } from 'multiformats/cid'
13
- import { code as jsonCode } from 'multiformats/codecs/json'
14
- import { code as rawCode } from 'multiformats/codecs/raw'
15
- import { identity } from 'multiformats/hashes/identity'
16
6
  import { CustomProgressEvent } from 'progress-events'
17
- import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
18
- import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
19
- import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
20
- import { ByteRangeContext } from './utils/byte-range-context.js'
21
- import { dagCborToSafeJSON } from './utils/dag-cbor-to-safe-json.js'
7
+ import { CarPlugin } from './plugins/plugin-handle-car.js'
8
+ import { DagCborPlugin } from './plugins/plugin-handle-dag-cbor.js'
9
+ import { DagPbPlugin } from './plugins/plugin-handle-dag-pb.js'
10
+ import { DagWalkPlugin } from './plugins/plugin-handle-dag-walk.js'
11
+ import { IpnsRecordPlugin } from './plugins/plugin-handle-ipns-record.js'
12
+ import { JsonPlugin } from './plugins/plugin-handle-json.js'
13
+ import { RawPlugin } from './plugins/plugin-handle-raw.js'
14
+ import { TarPlugin } from './plugins/plugin-handle-tar.js'
22
15
  import { getContentDispositionFilename } from './utils/get-content-disposition-filename.js'
23
16
  import { getETag } from './utils/get-e-tag.js'
24
- import { getPeerIdFromString } from './utils/get-peer-id-from-string.js'
25
17
  import { getResolvedAcceptHeader } from './utils/get-resolved-accept-header.js'
26
- import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterable.js'
27
- import { tarStream } from './utils/get-tar-stream.js'
28
18
  import { getRedirectResponse } from './utils/handle-redirects.js'
29
19
  import { parseResource } from './utils/parse-resource.js'
30
20
  import { type ParsedUrlStringResults } from './utils/parse-url-string.js'
31
21
  import { resourceToSessionCacheKey } from './utils/resource-to-cache-key.js'
32
- import { setCacheControlHeader, setIpfsRoots } from './utils/response-headers.js'
33
- import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse, badRangeResponse, okRangeResponse, badGatewayResponse, notFoundResponse } from './utils/responses.js'
22
+ import { setCacheControlHeader } from './utils/response-headers.js'
23
+ import { badRequestResponse, notAcceptableResponse, notSupportedResponse, badGatewayResponse } from './utils/responses.js'
34
24
  import { selectOutputType } from './utils/select-output-type.js'
35
- import { setContentType } from './utils/set-content-type.js'
36
- import { handlePathWalking, isObjectNode } from './utils/walk-path.js'
25
+ import { serverTiming } from './utils/server-timing.js'
37
26
  import type { CIDDetail, ContentTypeParser, CreateVerifiedFetchOptions, Resource, ResourceDetail, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
38
- import type { FetchHandlerFunctionArg, RequestFormatShorthand } from './types.js'
27
+ import type { VerifiedFetchPlugin, PluginContext, PluginOptions } from './plugins/types.js'
28
+ import type { RequestFormatShorthand } from './types.js'
39
29
  import type { Helia, SessionBlockstore } from '@helia/interface'
40
30
  import type { Blockstore } from 'interface-blockstore'
41
31
 
@@ -47,10 +37,6 @@ interface VerifiedFetchComponents {
47
37
  ipns?: IPNS
48
38
  }
49
39
 
50
- interface FetchHandlerFunction {
51
- (options: FetchHandlerFunctionArg): Promise<Response>
52
- }
53
-
54
40
  function convertOptions (options?: VerifiedFetchOptions): (Omit<VerifiedFetchOptions, 'signal'> & AbortOptions) | undefined {
55
41
  if (options == null) {
56
42
  return undefined
@@ -69,40 +55,14 @@ function convertOptions (options?: VerifiedFetchOptions): (Omit<VerifiedFetchOpt
69
55
  }
70
56
  }
71
57
 
72
- /**
73
- * These are Accept header values that will cause content type sniffing to be
74
- * skipped and set to these values.
75
- */
76
- const RAW_HEADERS = [
77
- 'application/vnd.ipld.dag-json',
78
- 'application/vnd.ipld.raw',
79
- 'application/octet-stream'
80
- ]
81
-
82
- /**
83
- * if the user has specified an `Accept` header, and it's in our list of
84
- * allowable "raw" format headers, use that instead of detecting the content
85
- * type. This avoids the user from receiving something different when they
86
- * signal that they want to `Accept` a specific mime type.
87
- */
88
- function getOverridenRawContentType ({ headers, accept }: { headers?: HeadersInit, accept?: string }): string | undefined {
89
- // accept has already been resolved by getResolvedAcceptHeader, if we have it, use it.
90
- const acceptHeader = accept ?? new Headers(headers).get('accept') ?? ''
91
-
92
- // e.g. "Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8"
93
- const acceptHeaders = acceptHeader.split(',')
94
- .map(s => s.split(';')[0])
95
- .map(s => s.trim())
96
-
97
- for (const mimeType of acceptHeaders) {
98
- if (mimeType === '*/*') {
99
- return
100
- }
101
-
102
- if (RAW_HEADERS.includes(mimeType ?? '')) {
103
- return mimeType
104
- }
105
- }
58
+ // TODO: merge/combine with PluginContext
59
+ interface FinalResponseContext {
60
+ cid?: ParsedUrlStringResults['cid']
61
+ reqFormat?: RequestFormatShorthand
62
+ ttl?: ParsedUrlStringResults['ttl']
63
+ protocol?: ParsedUrlStringResults['protocol']
64
+ ipfsPath?: string
65
+ query?: ParsedUrlStringResults['query']
106
66
  }
107
67
 
108
68
  export class VerifiedFetch {
@@ -111,6 +71,9 @@ export class VerifiedFetch {
111
71
  private readonly log: Logger
112
72
  private readonly contentTypeParser: ContentTypeParser | undefined
113
73
  private readonly blockstoreSessions: LRUCache<string, SessionBlockstore>
74
+ private serverTimingHeaders: string[] = []
75
+ private readonly withServerTiming: boolean
76
+ private readonly plugins: VerifiedFetchPlugin[] = []
114
77
 
115
78
  constructor ({ helia, ipns }: VerifiedFetchComponents, init?: CreateVerifiedFetchOptions) {
116
79
  this.helia = helia
@@ -124,10 +87,47 @@ export class VerifiedFetch {
124
87
  store.close()
125
88
  }
126
89
  })
90
+ this.withServerTiming = init?.withServerTiming ?? false
91
+
92
+ const pluginOptions: PluginOptions = {
93
+ ...init,
94
+ logger: prefixLogger('helia:verified-fetch'),
95
+ getBlockstore: (cid, resource, useSession, options) => this.getBlockstore(cid, resource, useSession, options),
96
+ handleServerTiming: async (name, description, fn) => this.handleServerTiming(name, description, fn, this.withServerTiming),
97
+ helia,
98
+ contentTypeParser: this.contentTypeParser
99
+ }
100
+
101
+ const defaultPlugins = [
102
+ new DagWalkPlugin(pluginOptions),
103
+ new IpnsRecordPlugin(pluginOptions),
104
+ new CarPlugin(pluginOptions),
105
+ new RawPlugin(pluginOptions),
106
+ new TarPlugin(pluginOptions),
107
+ new JsonPlugin(pluginOptions),
108
+ new DagCborPlugin(pluginOptions),
109
+ new DagPbPlugin(pluginOptions)
110
+ ]
111
+
112
+ const customPlugins = init?.plugins?.map((pluginFactory) => pluginFactory(pluginOptions)) ?? []
113
+
114
+ if (customPlugins.length > 0) {
115
+ // allow custom plugins to replace default plugins
116
+ const defaultPluginMap = new Map(defaultPlugins.map(plugin => [plugin.constructor.name, plugin]))
117
+ const customPluginMap = new Map(customPlugins.map(plugin => [plugin.constructor.name, plugin]))
118
+
119
+ this.plugins = defaultPlugins.map(plugin => customPluginMap.get(plugin.constructor.name) ?? plugin)
120
+
121
+ // Add any remaining custom plugins that don't replace a default plugin
122
+ this.plugins.push(...customPlugins.filter(plugin => !defaultPluginMap.has(plugin.constructor.name)))
123
+ } else {
124
+ this.plugins = defaultPlugins
125
+ }
126
+
127
127
  this.log.trace('created VerifiedFetch instance')
128
128
  }
129
129
 
130
- private getBlockstore (root: CID, resource: string | CID, useSession: boolean, options?: AbortOptions): Blockstore {
130
+ private getBlockstore (root: CID, resource: string | CID, useSession: boolean = true, options: AbortOptions = {}): Blockstore {
131
131
  const key = resourceToSessionCacheKey(resource)
132
132
  if (!useSession) {
133
133
  return this.helia.blockstore
@@ -143,313 +143,154 @@ export class VerifiedFetch {
143
143
  return session
144
144
  }
145
145
 
146
- /**
147
- * Accepts an `ipns://...` or `https?://<ipnsname>.ipns.<domain>` URL as a string and returns a `Response` containing
148
- * a raw IPNS record.
149
- */
150
- private async handleIPNSRecord ({ resource, cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
151
- if (path !== '' || !(resource.startsWith('ipns://') || resource.includes('.ipns.'))) {
152
- this.log.error('invalid request for IPNS name "%s" and path "%s"', resource, path)
153
- return badRequestResponse(resource, 'Invalid IPNS name')
146
+ private async handleServerTiming<T> (name: string, description: string, fn: () => Promise<T>, withServerTiming: boolean): Promise<T> {
147
+ if (!withServerTiming) {
148
+ return fn()
154
149
  }
155
-
156
- let peerId: PeerId
157
-
158
- try {
159
- if (resource.startsWith('ipns://')) {
160
- const peerIdString = resource.replace('ipns://', '')
161
- this.log.trace('trying to parse peer id from "%s"', peerIdString)
162
- peerId = getPeerIdFromString(peerIdString)
163
- } else {
164
- const peerIdString = resource.split('.ipns.')[0].split('://')[1]
165
- this.log.trace('trying to parse peer id from "%s"', peerIdString)
166
- peerId = getPeerIdFromString(peerIdString)
167
- }
168
- } catch (err: any) {
169
- this.log.error('could not parse peer id from IPNS url %s', resource, err)
170
-
171
- return badRequestResponse(resource, err)
150
+ const { error, result, header } = await serverTiming(name, description, fn)
151
+ this.serverTimingHeaders.push(header)
152
+ if (error != null) {
153
+ throw error
172
154
  }
173
155
 
174
- // since this call happens after parseResource, we've already resolved the
175
- // IPNS name so a local copy should be in the helia datastore, so we can
176
- // just read it out..
177
- const routingKey = uint8ArrayConcat([
178
- uint8ArrayFromString('/ipns/'),
179
- peerId.toMultihash().bytes
180
- ])
181
- const datastoreKey = new Key('/dht/record/' + uint8ArrayToString(routingKey, 'base32'), false)
182
- const buf = await this.helia.datastore.get(datastoreKey, options)
183
- const record = DHTRecord.deserialize(buf)
184
-
185
- const response = okResponse(resource, record.value)
186
- response.headers.set('content-type', 'application/vnd.ipfs.ipns-record')
187
-
188
- return response
189
- }
190
-
191
- /**
192
- * Accepts a `CID` and returns a `Response` with a body stream that is a CAR
193
- * of the `DAG` referenced by the `CID`.
194
- */
195
- private async handleCar ({ resource, cid, session, options }: FetchHandlerFunctionArg): Promise<Response> {
196
- const blockstore = this.getBlockstore(cid, resource, session, options)
197
- const c = car({ blockstore, getCodec: this.helia.getCodec })
198
- const stream = toBrowserReadableStream(c.stream(cid, options))
199
-
200
- const response = okResponse(resource, stream)
201
- response.headers.set('content-type', 'application/vnd.ipld.car; version=1')
202
-
203
- return response
156
+ return result
204
157
  }
205
158
 
206
159
  /**
207
- * Accepts a UnixFS `CID` and returns a `.tar` file containing the file or
208
- * directory structure referenced by the `CID`.
160
+ * The last place a Response touches in verified-fetch before being returned to the user. This is where we add the
161
+ * Server-Timing header to the response if it has been collected. It should be used for any final processing of the
162
+ * response before it is returned to the user.
209
163
  */
210
- private async handleTar ({ resource, cid, path, session, options }: FetchHandlerFunctionArg): Promise<Response> {
211
- if (cid.code !== dagPbCode && cid.code !== rawCode) {
212
- return notAcceptableResponse('only UnixFS data can be returned in a TAR file')
164
+ private handleFinalResponse (response: Response, { query, cid, reqFormat, ttl, protocol, ipfsPath }: FinalResponseContext = {}): Response {
165
+ if (this.serverTimingHeaders.length > 0) {
166
+ const headerString = this.serverTimingHeaders.join(', ')
167
+ response.headers.set('Server-Timing', headerString)
168
+ this.serverTimingHeaders = []
213
169
  }
214
170
 
215
- const blockstore = this.getBlockstore(cid, resource, session, options)
216
- const stream = toBrowserReadableStream<Uint8Array>(tarStream(`/ipfs/${cid}/${path}`, blockstore, options))
217
-
218
- const response = okResponse(resource, stream)
219
- response.headers.set('content-type', 'application/x-tar')
171
+ // set Content-Disposition header
172
+ let contentDisposition: string | undefined
220
173
 
221
- return response
222
- }
174
+ this.log.trace('checking for content disposition')
223
175
 
224
- private async handleJson ({ resource, cid, path, accept, session, options }: FetchHandlerFunctionArg): Promise<Response> {
225
- this.log.trace('fetching %c/%s', cid, path)
226
- const blockstore = this.getBlockstore(cid, resource, session, options)
227
- const block = await blockstore.get(cid, options)
228
- let body: string | Uint8Array
229
-
230
- if (accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
231
- try {
232
- // if vnd.ipld.dag-cbor has been specified, convert to the format - note
233
- // that this supports more data types than regular JSON, the content-type
234
- // response header is set so the user knows to process it differently
235
- const obj = ipldDagJson.decode(block)
236
- body = ipldDagCbor.encode(obj)
237
- } catch (err) {
238
- this.log.error('could not transform %c to application/vnd.ipld.dag-cbor', err)
239
- return notAcceptableResponse(resource)
240
- }
176
+ // force download if requested
177
+ if (query?.download === true) {
178
+ contentDisposition = 'attachment'
241
179
  } else {
242
- // skip decoding
243
- body = block
180
+ this.log.trace('download not requested')
244
181
  }
245
182
 
246
- const response = okResponse(resource, body)
247
- response.headers.set('content-type', accept ?? 'application/json')
248
- return response
249
- }
250
-
251
- private async handleDagCbor ({ resource, cid, path, accept, session, options }: FetchHandlerFunctionArg): Promise<Response> {
252
- this.log.trace('fetching %c/%s', cid, path)
253
- let terminalElement: ObjectNode
254
- const blockstore = this.getBlockstore(cid, resource, session, options)
183
+ // override filename if requested
184
+ if (query?.filename != null) {
185
+ if (contentDisposition == null) {
186
+ contentDisposition = 'inline'
187
+ }
255
188
 
256
- // need to walk path, if it exists, to get the terminal element
257
- const pathDetails = await handlePathWalking({ cid, path, resource, options, blockstore, log: this.log })
258
- if (pathDetails instanceof Response) {
259
- return pathDetails
260
- }
261
- const ipfsRoots = pathDetails.ipfsRoots
262
- if (isObjectNode(pathDetails.terminalElement)) {
263
- terminalElement = pathDetails.terminalElement
189
+ contentDisposition = `${contentDisposition}; ${getContentDispositionFilename(query.filename)}`
264
190
  } else {
265
- // this should never happen, but if it does, we should log it and return notSupportedResponse
266
- this.log.error('terminal element is not a dag-cbor node')
267
- return notSupportedResponse(resource, 'Terminal element is not a dag-cbor node')
191
+ this.log.trace('no filename specified in query')
268
192
  }
269
193
 
270
- const block = terminalElement.node
271
-
272
- let body: string | Uint8Array
273
-
274
- if (accept === 'application/octet-stream' || accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
275
- // skip decoding
276
- body = block
277
- } else if (accept === 'application/vnd.ipld.dag-json') {
278
- try {
279
- // if vnd.ipld.dag-json has been specified, convert to the format - note
280
- // that this supports more data types than regular JSON, the content-type
281
- // response header is set so the user knows to process it differently
282
- const obj = ipldDagCbor.decode(block)
283
- body = ipldDagJson.encode(obj)
284
- } catch (err) {
285
- this.log.error('could not transform %c to application/vnd.ipld.dag-json', err)
286
- return notAcceptableResponse(resource)
287
- }
194
+ if (contentDisposition != null) {
195
+ response.headers.set('Content-Disposition', contentDisposition)
288
196
  } else {
289
- try {
290
- body = dagCborToSafeJSON(block)
291
- } catch (err) {
292
- if (accept === 'application/json') {
293
- this.log('could not decode DAG-CBOR as JSON-safe, but the client sent "Accept: application/json"', err)
294
-
295
- return notAcceptableResponse(resource)
296
- }
297
-
298
- this.log('could not decode DAG-CBOR as JSON-safe, falling back to `application/octet-stream`', err)
299
- body = block
300
- }
197
+ this.log.trace('no content disposition specified')
301
198
  }
302
199
 
303
- const response = okResponse(resource, body)
304
-
305
- if (accept == null) {
306
- accept = body instanceof Uint8Array ? 'application/octet-stream' : 'application/json'
200
+ if (cid != null && response.headers.get('etag') == null) {
201
+ response.headers.set('etag', getETag({ cid, reqFormat, weak: false }))
307
202
  }
308
203
 
309
- response.headers.set('content-type', accept)
310
- setIpfsRoots(response, ipfsRoots)
204
+ if (protocol != null) {
205
+ setCacheControlHeader({ response, ttl, protocol })
206
+ }
207
+ if (ipfsPath != null) {
208
+ response.headers.set('X-Ipfs-Path', ipfsPath)
209
+ }
311
210
 
312
211
  return response
313
212
  }
314
213
 
315
- private async handleDagPb ({ cid, path, resource, session, options }: FetchHandlerFunctionArg): Promise<Response> {
316
- let redirected = false
317
- const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers)
318
- const blockstore = this.getBlockstore(cid, resource, session, options)
319
- const pathDetails = await handlePathWalking({ cid, path, resource, options, blockstore, log: this.log })
320
- if (pathDetails instanceof Response) {
321
- return pathDetails
322
- }
323
- const ipfsRoots = pathDetails.ipfsRoots
324
- const terminalElement = pathDetails.terminalElement
325
- let resolvedCID = terminalElement.cid
326
-
327
- if (terminalElement?.type === 'directory') {
328
- const dirCid = terminalElement.cid
329
- const redirectCheckNeeded = path === '' ? !resource.toString().endsWith('/') : !path.endsWith('/')
330
-
331
- // https://specs.ipfs.tech/http-gateways/path-gateway/#use-in-directory-url-normalization
332
- if (redirectCheckNeeded) {
333
- if (options?.redirect === 'error') {
334
- this.log('could not redirect to %s/ as redirect option was set to "error"', resource)
335
- throw new TypeError('Failed to fetch')
336
- } else if (options?.redirect === 'manual') {
337
- this.log('returning 301 permanent redirect to %s/', resource)
338
- return movedPermanentlyResponse(resource, `${resource}/`)
214
+ /**
215
+ * Runs plugins in a loop. After each plugin that returns `null` (partial/no final),
216
+ * we re-check `canHandle()` for all plugins in the next iteration if the context changed.
217
+ */
218
+ private async runPluginPipeline (context: PluginContext, maxPasses: number = 3): Promise<Response | undefined> {
219
+ let finalResponse: Response | undefined
220
+ let passCount = 0
221
+ const pluginsUsed = new Set<string>()
222
+
223
+ let prevModificationId = context.modified
224
+
225
+ while (passCount < maxPasses) {
226
+ this.log(`Starting pipeline pass #${passCount + 1}`)
227
+ passCount++
228
+
229
+ // gather plugins that say they can handle the *current* context, but haven't been used yet
230
+ const readyPlugins = this.plugins.filter(p => !pluginsUsed.has(p.constructor.name)).filter(p => p.canHandle(context))
231
+ if (readyPlugins.length === 0) {
232
+ this.log.trace('No plugins can handle the current context.. checking by CID code')
233
+ const plugins = this.plugins.filter(p => p.codes.includes(context.cid.code))
234
+ if (plugins.length > 0) {
235
+ readyPlugins.push(...plugins)
236
+ } else {
237
+ this.log.trace('No plugins found that can handle request by CID code; exiting pipeline.')
238
+ break
339
239
  }
340
-
341
- // fall-through simulates following the redirect?
342
- resource = `${resource}/`
343
- redirected = true
344
- }
345
-
346
- const rootFilePath = 'index.html'
347
- try {
348
- this.log.trace('found directory at %c/%s, looking for index.html', cid, path)
349
-
350
- const entry = await exporter(`/ipfs/${dirCid}/${rootFilePath}`, this.helia.blockstore, {
351
- signal: options?.signal,
352
- onProgress: options?.onProgress
353
- })
354
-
355
- this.log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, entry.cid)
356
- path = rootFilePath
357
- resolvedCID = entry.cid
358
- } catch (err: any) {
359
- options?.signal?.throwIfAborted()
360
- this.log('error loading path %c/%s', dirCid, rootFilePath, err)
361
- return notSupportedResponse('Unable to find index.html for directory at given path. Support for directories with implicit root is not implemented')
362
- } finally {
363
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: dirCid, path: rootFilePath }))
364
240
  }
365
- }
366
241
 
367
- // we have a validRangeRequest & terminalElement is a file, we know the size and should set it
368
- if (byteRangeContext.isRangeRequest && byteRangeContext.isValidRangeRequest && terminalElement.type === 'file') {
369
- byteRangeContext.setFileSize(terminalElement.unixfs.fileSize())
242
+ this.log.trace('Plugins ready to handle request: ', readyPlugins.map(p => p.constructor.name).join(', '))
243
+
244
+ // track if any plugin changed the context or returned a response
245
+ let contextChanged = false
246
+ let pluginHandled = false
247
+
248
+ for (const plugin of readyPlugins) {
249
+ try {
250
+ this.log.trace('Invoking plugin:', plugin.constructor.name)
251
+ pluginsUsed.add(plugin.constructor.name)
252
+
253
+ const maybeResponse = await plugin.handle(context)
254
+ if (maybeResponse != null) {
255
+ // if a plugin returns a final Response, short-circuit
256
+ finalResponse = maybeResponse
257
+ pluginHandled = true
258
+ break
259
+ }
260
+ } catch (err: any) {
261
+ context.options?.signal?.throwIfAborted()
262
+ this.log.error('Error in plugin:', plugin.constructor.name, err)
263
+ // if fatal, short-circuit the pipeline
264
+ if (err.name === 'PluginFatalError') {
265
+ // if plugin provides a custom error response, return it
266
+ return err.response ?? badGatewayResponse(context.resource, 'Failed to fetch')
267
+ }
268
+ } finally {
269
+ // on each plugin call, check for changes in the context
270
+ const newModificationId = context.modified
271
+ contextChanged = newModificationId !== prevModificationId
272
+ if (contextChanged) {
273
+ prevModificationId = newModificationId
274
+ }
275
+ }
370
276
 
371
- this.log.trace('fileSize for rangeRequest %d', byteRangeContext.getFileSize())
372
- }
373
- const offset = byteRangeContext.offset
374
- const length = byteRangeContext.length
375
- this.log.trace('calling exporter for %c/%s with offset=%o & length=%o', resolvedCID, path, offset, length)
277
+ if (finalResponse != null) {
278
+ this.log.trace('Plugin produced final response:', plugin.constructor.name)
279
+ break
280
+ }
281
+ }
376
282
 
377
- try {
378
- const entry = await exporter(resolvedCID, this.helia.blockstore, {
379
- signal: options?.signal,
380
- onProgress: options?.onProgress
381
- })
382
-
383
- const asyncIter = entry.content({
384
- signal: options?.signal,
385
- onProgress: options?.onProgress,
386
- offset,
387
- length
388
- })
389
- this.log('got async iterator for %c/%s', cid, path)
390
-
391
- const { stream, firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
392
- onProgress: options?.onProgress,
393
- signal: options?.signal
394
- })
395
- byteRangeContext.setBody(stream)
396
- // if not a valid range request, okRangeRequest will call okResponse
397
- const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log: this.log }, {
398
- redirected
399
- })
400
-
401
- await setContentType({ bytes: firstChunk, path, response, contentTypeParser: this.contentTypeParser, log: this.log })
402
- setIpfsRoots(response, ipfsRoots)
403
-
404
- return response
405
- } catch (err: any) {
406
- options?.signal?.throwIfAborted()
407
- this.log.error('error streaming %c/%s', cid, path, err)
408
- if (byteRangeContext.isRangeRequest && err.code === 'ERR_INVALID_PARAMS') {
409
- return badRangeResponse(resource)
283
+ if (pluginHandled && finalResponse != null) {
284
+ break
410
285
  }
411
- return badGatewayResponse(resource.toString(), 'Unable to stream content')
412
- }
413
- }
414
286
 
415
- private async handleRaw ({ resource, cid, path, session, options, accept }: FetchHandlerFunctionArg): Promise<Response> {
416
- /**
417
- * if we have a path, we can't walk it, so we need to return a 404.
418
- *
419
- * @see https://github.com/ipfs/gateway-conformance/blob/26994cfb056b717a23bf694ce4e94386728748dd/tests/subdomain_gateway_ipfs_test.go#L198-L204
420
- */
421
- if (path !== '') {
422
- this.log.trace('404-ing raw codec request for %c/%s', cid, path)
423
- return notFoundResponse(resource, 'Raw codec does not support paths')
287
+ if (!contextChanged) {
288
+ this.log.trace('No context changes and no final response; exiting pipeline.')
289
+ break
290
+ }
424
291
  }
425
292
 
426
- const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers)
427
- const blockstore = this.getBlockstore(cid, resource, session, options)
428
- const result = await blockstore.get(cid, options)
429
- byteRangeContext.setBody(result)
430
- const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log: this.log }, {
431
- redirected: false
432
- })
433
-
434
- // if the user has specified an `Accept` header that corresponds to a raw
435
- // type, honour that header, so for example they don't request
436
- // `application/vnd.ipld.raw` but get `application/octet-stream`
437
- await setContentType({ bytes: result, path, response, defaultContentType: getOverridenRawContentType({ headers: options?.headers, accept }), contentTypeParser: this.contentTypeParser, log: this.log })
438
-
439
- return response
440
- }
441
-
442
- /**
443
- * If the user has not specified an Accept header or format query string arg,
444
- * use the CID codec to choose an appropriate handler for the block data.
445
- */
446
- private readonly codecHandlers: Record<number, FetchHandlerFunction> = {
447
- [dagPbCode]: this.handleDagPb,
448
- [ipldDagJson.code]: this.handleJson,
449
- [jsonCode]: this.handleJson,
450
- [ipldDagCbor.code]: this.handleDagCbor,
451
- [rawCode]: this.handleRaw,
452
- [identity.code]: this.handleRaw
293
+ return finalResponse
453
294
  }
454
295
 
455
296
  /**
@@ -463,6 +304,7 @@ export class VerifiedFetch {
463
304
  this.log('fetch %s', resource)
464
305
 
465
306
  const options = convertOptions(opts)
307
+ const withServerTiming = options?.withServerTiming ?? this.withServerTiming
466
308
 
467
309
  options?.onProgress?.(new CustomProgressEvent<ResourceDetail>('verified-fetch:request:start', { resource }))
468
310
  // resolve the CID/path from the requested resource
@@ -473,105 +315,58 @@ export class VerifiedFetch {
473
315
  let protocol: ParsedUrlStringResults['protocol']
474
316
  let ipfsPath: string
475
317
  try {
476
- const result = await parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, options)
318
+ const result = await this.handleServerTiming('parse-resource', '', async () => parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, { withServerTiming, ...options }), withServerTiming)
477
319
  cid = result.cid
478
320
  path = result.path
479
321
  query = result.query
480
322
  ttl = result.ttl
481
323
  protocol = result.protocol
482
324
  ipfsPath = result.ipfsPath
325
+ this.serverTimingHeaders.push(...result.serverTimings.map(({ header }) => header))
483
326
  } catch (err: any) {
484
327
  options?.signal?.throwIfAborted()
485
328
  this.log.error('error parsing resource %s', resource, err)
486
329
 
487
- return badRequestResponse(resource.toString(), err)
330
+ return this.handleFinalResponse(badRequestResponse(resource.toString(), err))
488
331
  }
489
332
 
490
333
  options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:resolve', { cid, path }))
491
334
 
492
335
  const acceptHeader = getResolvedAcceptHeader({ query, headers: options?.headers, logger: this.helia.logger })
493
336
 
494
- const accept = selectOutputType(cid, acceptHeader)
337
+ const accept: string | undefined = selectOutputType(cid, acceptHeader)
495
338
  this.log('output type %s', accept)
496
339
 
497
340
  if (acceptHeader != null && accept == null) {
498
- return notAcceptableResponse(resource.toString())
341
+ return this.handleFinalResponse(notAcceptableResponse(resource.toString()))
499
342
  }
500
343
 
501
- let response: Response
502
- let reqFormat: RequestFormatShorthand | undefined
344
+ const responseContentType: string = accept?.split(';')[0] ?? 'application/octet-stream'
503
345
 
504
346
  const redirectResponse = await getRedirectResponse({ resource, options, logger: this.helia.logger, cid })
505
347
  if (redirectResponse != null) {
506
- return redirectResponse
507
- }
508
-
509
- const handlerArgs: FetchHandlerFunctionArg = { resource: resource.toString(), cid, path, accept, session: options?.session ?? true, options }
510
-
511
- if (accept === 'application/vnd.ipfs.ipns-record') {
512
- // the user requested a raw IPNS record
513
- reqFormat = 'ipns-record'
514
- response = await this.handleIPNSRecord(handlerArgs)
515
- } else if (accept === 'application/vnd.ipld.car') {
516
- // the user requested a CAR file
517
- reqFormat = 'car'
518
- query.download = true
519
- query.filename = query.filename ?? `${cid.toString()}.car`
520
- response = await this.handleCar(handlerArgs)
521
- } else if (accept === 'application/vnd.ipld.raw') {
522
- // the user requested a raw block
523
- reqFormat = 'raw'
524
- query.download = true
525
- query.filename = query.filename ?? `${cid.toString()}.bin`
526
- response = await this.handleRaw(handlerArgs)
527
- } else if (accept === 'application/x-tar') {
528
- // the user requested a TAR file
529
- reqFormat = 'tar'
530
- query.download = true
531
- query.filename = query.filename ?? `${cid.toString()}.tar`
532
- response = await this.handleTar(handlerArgs)
533
- } else {
534
- this.log.trace('finding handler for cid code "%s" and output type "%s"', cid.code, accept)
535
- // derive the handler from the CID type
536
- const codecHandler = this.codecHandlers[cid.code]
537
-
538
- if (codecHandler == null) {
539
- return notSupportedResponse(`Support for codec with code ${cid.code} is not yet implemented. Please open an issue at https://github.com/ipfs/helia-verified-fetch/issues/new`)
540
- }
541
- this.log.trace('calling handler "%s"', codecHandler.name)
542
-
543
- response = await codecHandler.call(this, handlerArgs)
348
+ return this.handleFinalResponse(redirectResponse)
544
349
  }
545
350
 
546
- response.headers.set('etag', getETag({ cid, reqFormat, weak: false }))
351
+ const context: PluginContext = { cid, path, resource: resource.toString(), accept, query, options, withServerTiming, onProgress: options?.onProgress, modified: 0 }
547
352
 
548
- setCacheControlHeader({ response, ttl, protocol })
549
- response.headers.set('X-Ipfs-Path', ipfsPath)
353
+ this.log.trace('finding handler for cid code "%s" and response content type "%s"', cid.code, responseContentType)
550
354
 
551
- // set Content-Disposition header
552
- let contentDisposition: string | undefined
553
-
554
- // force download if requested
555
- if (query.download === true) {
556
- contentDisposition = 'attachment'
557
- }
558
-
559
- // override filename if requested
560
- if (query.filename != null) {
561
- if (contentDisposition == null) {
562
- contentDisposition = 'inline'
563
- }
564
-
565
- contentDisposition = `${contentDisposition}; ${getContentDispositionFilename(query.filename)}`
566
- }
567
-
568
- if (contentDisposition != null) {
569
- response.headers.set('Content-Disposition', contentDisposition)
570
- }
355
+ const response = await this.runPluginPipeline(context)
571
356
 
572
357
  options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid, path }))
573
358
 
574
- return response
359
+ return this.handleFinalResponse(response ?? notSupportedResponse(resource.toString()), {
360
+ query: {
361
+ ...query,
362
+ ...context.query
363
+ },
364
+ cid,
365
+ reqFormat: context.reqFormat,
366
+ ttl,
367
+ protocol,
368
+ ipfsPath
369
+ })
575
370
  }
576
371
 
577
372
  /**