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