@helia/verified-fetch 0.0.0-f58d467 → 1.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 (54) hide show
  1. package/README.md +285 -25
  2. package/dist/index.min.js +7 -4
  3. package/dist/src/index.d.ts +285 -25
  4. package/dist/src/index.d.ts.map +1 -1
  5. package/dist/src/index.js +267 -25
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/types.d.ts +2 -0
  8. package/dist/src/types.d.ts.map +1 -0
  9. package/dist/src/types.js +2 -0
  10. package/dist/src/types.js.map +1 -0
  11. package/dist/src/utils/dag-cbor-to-safe-json.d.ts +7 -0
  12. package/dist/src/utils/dag-cbor-to-safe-json.d.ts.map +1 -0
  13. package/dist/src/utils/dag-cbor-to-safe-json.js +37 -0
  14. package/dist/src/utils/dag-cbor-to-safe-json.js.map +1 -0
  15. package/dist/src/utils/get-content-disposition-filename.d.ts +6 -0
  16. package/dist/src/utils/get-content-disposition-filename.d.ts.map +1 -0
  17. package/dist/src/utils/get-content-disposition-filename.js +16 -0
  18. package/dist/src/utils/get-content-disposition-filename.js.map +1 -0
  19. package/dist/src/utils/get-e-tag.d.ts +28 -0
  20. package/dist/src/utils/get-e-tag.d.ts.map +1 -0
  21. package/dist/src/utils/get-e-tag.js +18 -0
  22. package/dist/src/utils/get-e-tag.js.map +1 -0
  23. package/dist/src/utils/get-tar-stream.d.ts +4 -0
  24. package/dist/src/utils/get-tar-stream.d.ts.map +1 -0
  25. package/dist/src/utils/get-tar-stream.js +46 -0
  26. package/dist/src/utils/get-tar-stream.js.map +1 -0
  27. package/dist/src/utils/parse-url-string.d.ts +7 -1
  28. package/dist/src/utils/parse-url-string.d.ts.map +1 -1
  29. package/dist/src/utils/parse-url-string.js +6 -0
  30. package/dist/src/utils/parse-url-string.js.map +1 -1
  31. package/dist/src/utils/responses.d.ts +5 -0
  32. package/dist/src/utils/responses.d.ts.map +1 -0
  33. package/dist/src/utils/responses.js +27 -0
  34. package/dist/src/utils/responses.js.map +1 -0
  35. package/dist/src/utils/select-output-type.d.ts +12 -0
  36. package/dist/src/utils/select-output-type.d.ts.map +1 -0
  37. package/dist/src/utils/select-output-type.js +148 -0
  38. package/dist/src/utils/select-output-type.js.map +1 -0
  39. package/dist/src/verified-fetch.d.ts +19 -26
  40. package/dist/src/verified-fetch.d.ts.map +1 -1
  41. package/dist/src/verified-fetch.js +261 -142
  42. package/dist/src/verified-fetch.js.map +1 -1
  43. package/dist/typedoc-urls.json +27 -0
  44. package/package.json +49 -112
  45. package/src/index.ts +290 -29
  46. package/src/types.ts +1 -0
  47. package/src/utils/dag-cbor-to-safe-json.ts +44 -0
  48. package/src/utils/get-content-disposition-filename.ts +18 -0
  49. package/src/utils/get-e-tag.ts +36 -0
  50. package/src/utils/get-tar-stream.ts +68 -0
  51. package/src/utils/parse-url-string.ts +17 -2
  52. package/src/utils/responses.ts +29 -0
  53. package/src/utils/select-output-type.ts +167 -0
  54. package/src/verified-fetch.ts +310 -154
@@ -1,21 +1,34 @@
1
- import { dagCbor as heliaDagCbor, type DAGCBOR } from '@helia/dag-cbor'
2
- import { dagJson as heliaDagJson, type DAGJSON } from '@helia/dag-json'
3
- import { ipns as heliaIpns, type IPNS } from '@helia/ipns'
1
+ import { car } from '@helia/car'
2
+ import { ipns as heliaIpns, type DNSResolver, type IPNS } from '@helia/ipns'
4
3
  import { dnsJsonOverHttps } from '@helia/ipns/dns-resolvers'
5
- import { json as heliaJson, type JSON } from '@helia/json'
6
4
  import { unixfs as heliaUnixFs, type UnixFS as HeliaUnixFs, type UnixFSStats } from '@helia/unixfs'
7
- import { code as dagCborCode } from '@ipld/dag-cbor'
8
- import { code as dagJsonCode } from '@ipld/dag-json'
5
+ import * as ipldDagCbor from '@ipld/dag-cbor'
6
+ import * as ipldDagJson from '@ipld/dag-json'
9
7
  import { code as dagPbCode } from '@ipld/dag-pb'
8
+ import { Record as DHTRecord } from '@libp2p/kad-dht'
9
+ import { peerIdFromString } from '@libp2p/peer-id'
10
+ import { Key } from 'interface-datastore'
11
+ import toBrowserReadableStream from 'it-to-browser-readablestream'
10
12
  import { code as jsonCode } from 'multiformats/codecs/json'
11
- import { decode, code as rawCode } from 'multiformats/codecs/raw'
13
+ import { code as rawCode } from 'multiformats/codecs/raw'
14
+ import { identity } from 'multiformats/hashes/identity'
12
15
  import { CustomProgressEvent } from 'progress-events'
16
+ import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
17
+ import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
18
+ import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
19
+ import { dagCborToSafeJSON } from './utils/dag-cbor-to-safe-json.js'
20
+ import { getContentDispositionFilename } from './utils/get-content-disposition-filename.js'
21
+ import { getETag } from './utils/get-e-tag.js'
13
22
  import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterable.js'
23
+ import { tarStream } from './utils/get-tar-stream.js'
14
24
  import { parseResource } from './utils/parse-resource.js'
15
- import { walkPath, type PathWalkerFn } from './utils/walk-path.js'
25
+ import { badRequestResponse, notAcceptableResponse, notSupportedResponse, okResponse } from './utils/responses.js'
26
+ import { selectOutputType, queryFormatToAcceptHeader } from './utils/select-output-type.js'
27
+ import { walkPath } from './utils/walk-path.js'
16
28
  import type { CIDDetail, ContentTypeParser, Resource, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
29
+ import type { RequestFormatShorthand } from './types.js'
17
30
  import type { Helia } from '@helia/interface'
18
- import type { AbortOptions, Logger } from '@libp2p/interface'
31
+ import type { AbortOptions, Logger, PeerId } from '@libp2p/interface'
19
32
  import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
20
33
  import type { CID } from 'multiformats/cid'
21
34
 
@@ -23,10 +36,6 @@ interface VerifiedFetchComponents {
23
36
  helia: Helia
24
37
  ipns?: IPNS
25
38
  unixfs?: HeliaUnixFs
26
- dagJson?: DAGJSON
27
- json?: JSON
28
- dagCbor?: DAGCBOR
29
- pathWalker?: PathWalkerFn
30
39
  }
31
40
 
32
41
  /**
@@ -34,13 +43,24 @@ interface VerifiedFetchComponents {
34
43
  */
35
44
  interface VerifiedFetchInit {
36
45
  contentTypeParser?: ContentTypeParser
46
+ dnsResolvers?: DNSResolver[]
37
47
  }
38
48
 
39
49
  interface FetchHandlerFunctionArg {
40
50
  cid: CID
41
51
  path: string
42
- terminalElement?: UnixFSEntry
43
52
  options?: Omit<VerifiedFetchOptions, 'signal'> & AbortOptions
53
+
54
+ /**
55
+ * If present, the user has sent an accept header with this value - if the
56
+ * content cannot be represented in this format a 406 should be returned
57
+ */
58
+ accept?: string
59
+
60
+ /**
61
+ * The originally requested resource
62
+ */
63
+ resource: string
44
64
  }
45
65
 
46
66
  interface FetchHandlerFunction {
@@ -62,90 +82,212 @@ function convertOptions (options?: VerifiedFetchOptions): (Omit<VerifiedFetchOpt
62
82
  }
63
83
  }
64
84
 
85
+ /**
86
+ * These are Accept header values that will cause content type sniffing to be
87
+ * skipped and set to these values.
88
+ */
89
+ const RAW_HEADERS = [
90
+ 'application/vnd.ipld.raw',
91
+ 'application/octet-stream'
92
+ ]
93
+
94
+ /**
95
+ * if the user has specified an `Accept` header, and it's in our list of
96
+ * allowable "raw" format headers, use that instead of detecting the content
97
+ * type. This avoids the user from receiving something different when they
98
+ * signal that they want to `Accept` a specific mime type.
99
+ */
100
+ function getOverridenRawContentType (headers?: HeadersInit): string | undefined {
101
+ const acceptHeader = new Headers(headers).get('accept') ?? ''
102
+
103
+ // e.g. "Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8"
104
+ const acceptHeaders = acceptHeader.split(',')
105
+ .map(s => s.split(';')[0])
106
+ .map(s => s.trim())
107
+
108
+ for (const mimeType of acceptHeaders) {
109
+ if (mimeType === '*/*') {
110
+ return
111
+ }
112
+
113
+ if (RAW_HEADERS.includes(mimeType ?? '')) {
114
+ return mimeType
115
+ }
116
+ }
117
+ }
118
+
65
119
  export class VerifiedFetch {
66
120
  private readonly helia: Helia
67
121
  private readonly ipns: IPNS
68
122
  private readonly unixfs: HeliaUnixFs
69
- private readonly dagJson: DAGJSON
70
- private readonly dagCbor: DAGCBOR
71
- private readonly json: JSON
72
- private readonly pathWalker: PathWalkerFn
73
123
  private readonly log: Logger
74
124
  private readonly contentTypeParser: ContentTypeParser | undefined
75
125
 
76
- constructor ({ helia, ipns, unixfs, dagJson, json, dagCbor, pathWalker }: VerifiedFetchComponents, init?: VerifiedFetchInit) {
126
+ constructor ({ helia, ipns, unixfs }: VerifiedFetchComponents, init?: VerifiedFetchInit) {
77
127
  this.helia = helia
78
128
  this.log = helia.logger.forComponent('helia:verified-fetch')
79
129
  this.ipns = ipns ?? heliaIpns(helia, {
80
- resolvers: [
130
+ resolvers: init?.dnsResolvers ?? [
81
131
  dnsJsonOverHttps('https://mozilla.cloudflare-dns.com/dns-query'),
82
132
  dnsJsonOverHttps('https://dns.google/resolve')
83
133
  ]
84
134
  })
85
135
  this.unixfs = unixfs ?? heliaUnixFs(helia)
86
- this.dagJson = dagJson ?? heliaDagJson(helia)
87
- this.json = json ?? heliaJson(helia)
88
- this.dagCbor = dagCbor ?? heliaDagCbor(helia)
89
- this.pathWalker = pathWalker ?? walkPath
90
136
  this.contentTypeParser = init?.contentTypeParser
91
137
  this.log.trace('created VerifiedFetch instance')
92
138
  }
93
139
 
94
- // handle vnd.ipfs.ipns-record
95
- private async handleIPNSRecord ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
96
- const response = new Response('vnd.ipfs.ipns-record support is not implemented', { status: 501 })
97
- response.headers.set('X-Content-Type-Options', 'nosniff') // see https://specs.ipfs.tech/http-gateways/path-gateway/#x-content-type-options-response-header
140
+ /**
141
+ * Accepts an `ipns://...` URL as a string and returns a `Response` containing
142
+ * a raw IPNS record.
143
+ */
144
+ private async handleIPNSRecord ({ resource, cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
145
+ if (path !== '' || !resource.startsWith('ipns://')) {
146
+ return badRequestResponse('Invalid IPNS name')
147
+ }
148
+
149
+ let peerId: PeerId
150
+
151
+ try {
152
+ peerId = peerIdFromString(resource.replace('ipns://', ''))
153
+ } catch (err) {
154
+ this.log.error('could not parse peer id from IPNS url %s', resource)
155
+
156
+ return badRequestResponse('Invalid IPNS name')
157
+ }
158
+
159
+ // since this call happens after parseResource, we've already resolved the
160
+ // IPNS name so a local copy should be in the helia datastore, so we can
161
+ // just read it out..
162
+ const routingKey = uint8ArrayConcat([
163
+ uint8ArrayFromString('/ipns/'),
164
+ peerId.toBytes()
165
+ ])
166
+ const datastoreKey = new Key('/dht/record/' + uint8ArrayToString(routingKey, 'base32'), false)
167
+ const buf = await this.helia.datastore.get(datastoreKey, options)
168
+ const record = DHTRecord.deserialize(buf)
169
+
170
+ const response = okResponse(record.value)
171
+ response.headers.set('content-type', 'application/vnd.ipfs.ipns-record')
172
+
98
173
  return response
99
174
  }
100
175
 
101
- // handle vnd.ipld.car
102
- private async handleIPLDCar ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
103
- const response = new Response('vnd.ipld.car support is not implemented', { status: 501 })
104
- response.headers.set('X-Content-Type-Options', 'nosniff') // see https://specs.ipfs.tech/http-gateways/path-gateway/#x-content-type-options-response-header
176
+ /**
177
+ * Accepts a `CID` and returns a `Response` with a body stream that is a CAR
178
+ * of the `DAG` referenced by the `CID`.
179
+ */
180
+ private async handleCar ({ cid, options }: FetchHandlerFunctionArg): Promise<Response> {
181
+ const c = car(this.helia)
182
+ const stream = toBrowserReadableStream(c.stream(cid, options))
183
+
184
+ const response = okResponse(stream)
185
+ response.headers.set('content-type', 'application/vnd.ipld.car; version=1')
186
+
105
187
  return response
106
188
  }
107
189
 
108
- private async handleDagJson ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
109
- this.log.trace('fetching %c/%s', cid, path)
110
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: cid.toString(), path }))
111
- const result = await this.dagJson.get(cid, {
112
- signal: options?.signal,
113
- onProgress: options?.onProgress
114
- })
115
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: cid.toString(), path }))
116
- const response = new Response(JSON.stringify(result), { status: 200 })
117
- response.headers.set('content-type', 'application/json')
190
+ /**
191
+ * Accepts a UnixFS `CID` and returns a `.tar` file containing the file or
192
+ * directory structure referenced by the `CID`.
193
+ */
194
+ private async handleTar ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
195
+ if (cid.code !== dagPbCode && cid.code !== rawCode) {
196
+ return notAcceptableResponse('only UnixFS data can be returned in a TAR file')
197
+ }
198
+
199
+ const stream = toBrowserReadableStream<Uint8Array>(tarStream(`/ipfs/${cid}/${path}`, this.helia.blockstore, options))
200
+
201
+ const response = okResponse(stream)
202
+ response.headers.set('content-type', 'application/x-tar')
203
+
118
204
  return response
119
205
  }
120
206
 
121
- private async handleJson ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
207
+ private async handleJson ({ cid, path, accept, options }: FetchHandlerFunctionArg): Promise<Response> {
122
208
  this.log.trace('fetching %c/%s', cid, path)
123
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: cid.toString(), path }))
124
- const result: Record<any, any> = await this.json.get(cid, {
125
- signal: options?.signal,
126
- onProgress: options?.onProgress
127
- })
128
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: cid.toString(), path }))
129
- const response = new Response(JSON.stringify(result), { status: 200 })
130
- response.headers.set('content-type', 'application/json')
209
+ const block = await this.helia.blockstore.get(cid, options)
210
+ let body: string | Uint8Array
211
+
212
+ if (accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
213
+ try {
214
+ // if vnd.ipld.dag-cbor has been specified, convert to the format - note
215
+ // that this supports more data types than regular JSON, the content-type
216
+ // response header is set so the user knows to process it differently
217
+ const obj = ipldDagJson.decode(block)
218
+ body = ipldDagCbor.encode(obj)
219
+ } catch (err) {
220
+ this.log.error('could not transform %c to application/vnd.ipld.dag-cbor', err)
221
+ return notAcceptableResponse()
222
+ }
223
+ } else {
224
+ // skip decoding
225
+ body = block
226
+ }
227
+
228
+ const response = okResponse(body)
229
+ response.headers.set('content-type', accept ?? 'application/json')
131
230
  return response
132
231
  }
133
232
 
134
- private async handleDagCbor ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
233
+ private async handleDagCbor ({ cid, path, accept, options }: FetchHandlerFunctionArg): Promise<Response> {
135
234
  this.log.trace('fetching %c/%s', cid, path)
136
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: cid.toString(), path }))
137
- const result = await this.dagCbor.get<Uint8Array>(cid, {
138
- signal: options?.signal,
139
- onProgress: options?.onProgress
140
- })
141
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: cid.toString(), path }))
142
- const response = new Response(result, { status: 200 })
143
- await this.setContentType(result, path, response)
235
+
236
+ const block = await this.helia.blockstore.get(cid, options)
237
+ let body: string | Uint8Array
238
+
239
+ if (accept === 'application/octet-stream' || accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
240
+ // skip decoding
241
+ body = block
242
+ } else if (accept === 'application/vnd.ipld.dag-json') {
243
+ try {
244
+ // if vnd.ipld.dag-json has been specified, convert to the format - note
245
+ // that this supports more data types than regular JSON, the content-type
246
+ // response header is set so the user knows to process it differently
247
+ const obj = ipldDagCbor.decode(block)
248
+ body = ipldDagJson.encode(obj)
249
+ } catch (err) {
250
+ this.log.error('could not transform %c to application/vnd.ipld.dag-json', err)
251
+ return notAcceptableResponse()
252
+ }
253
+ } else {
254
+ try {
255
+ body = dagCborToSafeJSON(block)
256
+ } catch (err) {
257
+ if (accept === 'application/json') {
258
+ this.log('could not decode DAG-CBOR as JSON-safe, but the client sent "Accept: application/json"', err)
259
+
260
+ return notAcceptableResponse()
261
+ }
262
+
263
+ this.log('could not decode DAG-CBOR as JSON-safe, falling back to `application/octet-stream`', err)
264
+ body = block
265
+ }
266
+ }
267
+
268
+ const response = okResponse(body)
269
+
270
+ if (accept == null) {
271
+ accept = body instanceof Uint8Array ? 'application/octet-stream' : 'application/json'
272
+ }
273
+
274
+ response.headers.set('content-type', accept)
275
+
144
276
  return response
145
277
  }
146
278
 
147
- private async handleDagPb ({ cid, path, options, terminalElement }: FetchHandlerFunctionArg): Promise<Response> {
148
- this.log.trace('fetching %c/%s', cid, path)
279
+ private async handleDagPb ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
280
+ let terminalElement: UnixFSEntry | undefined
281
+ let ipfsRoots: CID[] | undefined
282
+
283
+ try {
284
+ const pathDetails = await walkPath(this.helia.blockstore, `${cid.toString()}/${path}`, options)
285
+ ipfsRoots = pathDetails.ipfsRoots
286
+ terminalElement = pathDetails.terminalElement
287
+ } catch (err) {
288
+ this.log.error('Error walking path %s', path, err)
289
+ }
290
+
149
291
  let resolvedCID = terminalElement?.cid ?? cid
150
292
  let stat: UnixFSStats
151
293
  if (terminalElement?.type === 'directory') {
@@ -154,7 +296,6 @@ export class VerifiedFetch {
154
296
  const rootFilePath = 'index.html'
155
297
  try {
156
298
  this.log.trace('found directory at %c/%s, looking for index.html', cid, path)
157
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: dirCid.toString(), path: rootFilePath }))
158
299
  stat = await this.unixfs.stat(dirCid, {
159
300
  path: rootFilePath,
160
301
  signal: options?.signal,
@@ -166,36 +307,45 @@ export class VerifiedFetch {
166
307
  // terminalElement = stat
167
308
  } catch (err: any) {
168
309
  this.log('error loading path %c/%s', dirCid, rootFilePath, err)
169
- return new Response('Unable to find index.html for directory at given path. Support for directories with implicit root is not implemented', { status: 501 })
310
+ return notSupportedResponse('Unable to find index.html for directory at given path. Support for directories with implicit root is not implemented')
170
311
  } finally {
171
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: dirCid.toString(), path: rootFilePath }))
312
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: dirCid, path: rootFilePath }))
172
313
  }
173
314
  }
174
315
 
175
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: resolvedCID.toString(), path: '' }))
176
316
  const asyncIter = this.unixfs.cat(resolvedCID, {
177
317
  signal: options?.signal,
178
318
  onProgress: options?.onProgress
179
319
  })
180
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: resolvedCID.toString(), path: '' }))
181
320
  this.log('got async iterator for %c/%s', cid, path)
182
321
 
183
322
  const { stream, firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
184
323
  onProgress: options?.onProgress
185
324
  })
186
- const response = new Response(stream, { status: 200 })
325
+ const response = okResponse(stream)
187
326
  await this.setContentType(firstChunk, path, response)
188
327
 
328
+ if (ipfsRoots != null) {
329
+ response.headers.set('X-Ipfs-Roots', ipfsRoots.map(cid => cid.toV1().toString()).join(',')) // https://specs.ipfs.tech/http-gateways/path-gateway/#x-ipfs-roots-response-header
330
+ }
331
+
189
332
  return response
190
333
  }
191
334
 
192
335
  private async handleRaw ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
193
- this.log.trace('fetching %c/%s', cid, path)
194
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: cid.toString(), path }))
195
- const result = await this.helia.blockstore.get(cid)
196
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: cid.toString(), path }))
197
- const response = new Response(decode(result), { status: 200 })
198
- await this.setContentType(result, path, response)
336
+ const result = await this.helia.blockstore.get(cid, options)
337
+ const response = okResponse(result)
338
+
339
+ // if the user has specified an `Accept` header that corresponds to a raw
340
+ // type, honour that header, so for example they don't request
341
+ // `application/vnd.ipld.raw` but get `application/octet-stream`
342
+ const overriddenContentType = getOverridenRawContentType(options?.headers)
343
+ if (overriddenContentType != null) {
344
+ response.headers.set('content-type', overriddenContentType)
345
+ } else {
346
+ await this.setContentType(result, path, response)
347
+ }
348
+
199
349
  return response
200
350
  }
201
351
 
@@ -226,110 +376,116 @@ export class VerifiedFetch {
226
376
  }
227
377
 
228
378
  /**
229
- * Determines the format requested by the client, defaults to `null` if no format is requested.
230
- *
231
- * @see https://specs.ipfs.tech/http-gateways/path-gateway/#format-request-query-parameter
232
- * @default 'raw'
233
- */
234
- private getFormat ({ headerFormat, queryFormat }: { headerFormat: string | null, queryFormat: string | null }): string | null {
235
- const formatMap: Record<string, string> = {
236
- 'vnd.ipld.raw': 'raw',
237
- 'vnd.ipld.car': 'car',
238
- 'application/x-tar': 'tar',
239
- 'application/vnd.ipld.dag-json': 'dag-json',
240
- 'application/vnd.ipld.dag-cbor': 'dag-cbor',
241
- 'application/json': 'json',
242
- 'application/cbor': 'cbor',
243
- 'vnd.ipfs.ipns-record': 'ipns-record'
244
- }
245
-
246
- if (headerFormat != null) {
247
- for (const format in formatMap) {
248
- if (headerFormat.includes(format)) {
249
- return formatMap[format]
250
- }
251
- }
252
- } else if (queryFormat != null) {
253
- return queryFormat
254
- }
255
-
256
- return null
257
- }
258
-
259
- /**
260
- * Map of format to specific handlers for that format.
261
- * These format handlers should adjust the response headers as specified in https://specs.ipfs.tech/http-gateways/path-gateway/#response-headers
379
+ * If the user has not specified an Accept header or format query string arg,
380
+ * use the CID codec to choose an appropriate handler for the block data.
262
381
  */
263
- private readonly formatHandlers: Record<string, FetchHandlerFunction> = {
264
- raw: async () => new Response('application/vnd.ipld.raw support is not implemented', { status: 501 }),
265
- car: this.handleIPLDCar,
266
- 'ipns-record': this.handleIPNSRecord,
267
- tar: async () => new Response('application/x-tar support is not implemented', { status: 501 }),
268
- 'dag-json': async () => new Response('application/vnd.ipld.dag-json support is not implemented', { status: 501 }),
269
- 'dag-cbor': async () => new Response('application/vnd.ipld.dag-cbor support is not implemented', { status: 501 }),
270
- json: async () => new Response('application/json support is not implemented', { status: 501 }),
271
- cbor: async () => new Response('application/cbor support is not implemented', { status: 501 })
272
- }
273
-
274
382
  private readonly codecHandlers: Record<number, FetchHandlerFunction> = {
275
- [dagJsonCode]: this.handleDagJson,
276
383
  [dagPbCode]: this.handleDagPb,
384
+ [ipldDagJson.code]: this.handleJson,
277
385
  [jsonCode]: this.handleJson,
278
- [dagCborCode]: this.handleDagCbor,
279
- [rawCode]: this.handleRaw
386
+ [ipldDagCbor.code]: this.handleDagCbor,
387
+ [rawCode]: this.handleRaw,
388
+ [identity.code]: this.handleRaw
280
389
  }
281
390
 
282
391
  async fetch (resource: Resource, opts?: VerifiedFetchOptions): Promise<Response> {
392
+ this.log('fetch %s', resource)
393
+
283
394
  const options = convertOptions(opts)
284
- const { path, query, ...rest } = await parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, options)
285
- const cid = rest.cid
286
- let response: Response | undefined
287
395
 
288
- const format = this.getFormat({ headerFormat: new Headers(options?.headers).get('accept'), queryFormat: query.format ?? null })
396
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { resource }))
289
397
 
290
- if (format != null) {
291
- // TODO: These should be handled last when they're returning something other than 501
292
- const formatHandler = this.formatHandlers[format]
398
+ // resolve the CID/path from the requested resource
399
+ const { path, query, cid } = await parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, options)
293
400
 
294
- if (formatHandler != null) {
295
- response = await formatHandler.call(this, { cid, path, options })
401
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:resolve', { cid, path }))
296
402
 
297
- if (response.status === 501) {
298
- return response
299
- }
300
- }
403
+ const requestHeaders = new Headers(options?.headers)
404
+ const incomingAcceptHeader = requestHeaders.get('accept')
405
+
406
+ if (incomingAcceptHeader != null) {
407
+ this.log('incoming accept header "%s"', incomingAcceptHeader)
301
408
  }
302
409
 
303
- let terminalElement: UnixFSEntry | undefined
304
- let ipfsRoots: CID[] | undefined
410
+ const queryFormatMapping = queryFormatToAcceptHeader(query.format)
305
411
 
306
- try {
307
- const pathDetails = await this.pathWalker(this.helia.blockstore, `${cid.toString()}/${path}`, options)
308
- ipfsRoots = pathDetails.ipfsRoots
309
- terminalElement = pathDetails.terminalElement
310
- } catch (err) {
311
- this.log.error('Error walking path %s', path, err)
312
- // return new Response(`Error walking path: ${(err as Error).message}`, { status: 500 })
412
+ if (query.format != null) {
413
+ this.log('incoming query format "%s", mapped to %s', query.format, queryFormatMapping)
414
+ }
415
+
416
+ const acceptHeader = incomingAcceptHeader ?? queryFormatMapping
417
+ const accept = selectOutputType(cid, acceptHeader)
418
+ this.log('output type %s', accept)
419
+
420
+ if (acceptHeader != null && accept == null) {
421
+ return notAcceptableResponse()
313
422
  }
314
423
 
315
- if (response == null) {
424
+ let response: Response
425
+ let reqFormat: RequestFormatShorthand | undefined
426
+
427
+ const handlerArgs = { resource: resource.toString(), cid, path, accept, options }
428
+
429
+ if (accept === 'application/vnd.ipfs.ipns-record') {
430
+ // the user requested a raw IPNS record
431
+ reqFormat = 'ipns-record'
432
+ response = await this.handleIPNSRecord(handlerArgs)
433
+ } else if (accept === 'application/vnd.ipld.car') {
434
+ // the user requested a CAR file
435
+ reqFormat = 'car'
436
+ query.download = true
437
+ query.filename = query.filename ?? `${cid.toString()}.car`
438
+ response = await this.handleCar(handlerArgs)
439
+ } else if (accept === 'application/vnd.ipld.raw') {
440
+ // the user requested a raw block
441
+ reqFormat = 'raw'
442
+ query.download = true
443
+ query.filename = query.filename ?? `${cid.toString()}.bin`
444
+ response = await this.handleRaw(handlerArgs)
445
+ } else if (accept === 'application/x-tar') {
446
+ // the user requested a TAR file
447
+ reqFormat = 'tar'
448
+ query.download = true
449
+ query.filename = query.filename ?? `${cid.toString()}.tar`
450
+ response = await this.handleTar(handlerArgs)
451
+ } else {
452
+ // derive the handler from the CID type
316
453
  const codecHandler = this.codecHandlers[cid.code]
317
454
 
318
- if (codecHandler != null) {
319
- response = await codecHandler.call(this, { cid, path, options, terminalElement })
320
- } else {
321
- return new Response(`Support for codec with code ${cid.code} is not yet implemented. Please open an issue at https://github.com/ipfs/helia/issues/new`, { status: 501 })
455
+ if (codecHandler == null) {
456
+ return notSupportedResponse(`Support for codec with code ${cid.code} is not yet implemented. Please open an issue at https://github.com/ipfs/helia/issues/new`)
322
457
  }
458
+
459
+ response = await codecHandler.call(this, handlerArgs)
323
460
  }
324
461
 
325
- response.headers.set('etag', cid.toString()) // https://specs.ipfs.tech/http-gateways/path-gateway/#etag-response-header
326
- response.headers.set('cache-cotrol', 'public, max-age=29030400, immutable')
327
- response.headers.set('X-Ipfs-Path', resource.toString()) // https://specs.ipfs.tech/http-gateways/path-gateway/#x-ipfs-path-response-header
462
+ response.headers.set('etag', getETag({ cid, reqFormat, weak: false }))
463
+ response.headers.set('cache-control', 'public, max-age=29030400, immutable')
464
+ // https://specs.ipfs.tech/http-gateways/path-gateway/#x-ipfs-path-response-header
465
+ response.headers.set('X-Ipfs-Path', resource.toString())
328
466
 
329
- if (ipfsRoots != null) {
330
- response.headers.set('X-Ipfs-Roots', ipfsRoots.map(cid => cid.toV1().toString()).join(',')) // https://specs.ipfs.tech/http-gateways/path-gateway/#x-ipfs-roots-response-header
467
+ // set Content-Disposition header
468
+ let contentDisposition: string | undefined
469
+
470
+ // force download if requested
471
+ if (query.download === true) {
472
+ contentDisposition = 'attachment'
473
+ }
474
+
475
+ // override filename if requested
476
+ if (query.filename != null) {
477
+ if (contentDisposition == null) {
478
+ contentDisposition = 'inline'
479
+ }
480
+
481
+ contentDisposition = `${contentDisposition}; ${getContentDispositionFilename(query.filename)}`
482
+ }
483
+
484
+ if (contentDisposition != null) {
485
+ response.headers.set('Content-Disposition', contentDisposition)
331
486
  }
332
- // response.headers.set('Content-Disposition', `TODO`) // https://specs.ipfs.tech/http-gateways/path-gateway/#content-disposition-response-header
487
+
488
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid, path }))
333
489
 
334
490
  return response
335
491
  }