@helia/verified-fetch 0.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +353 -56
  2. package/dist/index.min.js +7 -29
  3. package/dist/src/index.d.ts +384 -69
  4. package/dist/src/index.d.ts.map +1 -1
  5. package/dist/src/index.js +345 -77
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/singleton.d.ts +3 -0
  8. package/dist/src/singleton.d.ts.map +1 -0
  9. package/dist/src/singleton.js +15 -0
  10. package/dist/src/singleton.js.map +1 -0
  11. package/dist/src/types.d.ts +2 -0
  12. package/dist/src/types.d.ts.map +1 -0
  13. package/dist/src/types.js +2 -0
  14. package/dist/src/types.js.map +1 -0
  15. package/dist/src/utils/dag-cbor-to-safe-json.d.ts +7 -0
  16. package/dist/src/utils/dag-cbor-to-safe-json.d.ts.map +1 -0
  17. package/dist/src/utils/dag-cbor-to-safe-json.js +25 -0
  18. package/dist/src/utils/dag-cbor-to-safe-json.js.map +1 -0
  19. package/dist/src/utils/get-content-disposition-filename.d.ts +6 -0
  20. package/dist/src/utils/get-content-disposition-filename.d.ts.map +1 -0
  21. package/dist/src/utils/get-content-disposition-filename.js +16 -0
  22. package/dist/src/utils/get-content-disposition-filename.js.map +1 -0
  23. package/dist/src/utils/get-e-tag.d.ts +28 -0
  24. package/dist/src/utils/get-e-tag.d.ts.map +1 -0
  25. package/dist/src/utils/get-e-tag.js +18 -0
  26. package/dist/src/utils/get-e-tag.js.map +1 -0
  27. package/dist/src/utils/get-stream-from-async-iterable.d.ts +10 -0
  28. package/dist/src/utils/get-stream-from-async-iterable.d.ts.map +1 -0
  29. package/dist/src/utils/{get-stream-and-content-type.js → get-stream-from-async-iterable.js} +11 -11
  30. package/dist/src/utils/get-stream-from-async-iterable.js.map +1 -0
  31. package/dist/src/utils/get-tar-stream.d.ts +4 -0
  32. package/dist/src/utils/get-tar-stream.d.ts.map +1 -0
  33. package/dist/src/utils/get-tar-stream.js +46 -0
  34. package/dist/src/utils/get-tar-stream.js.map +1 -0
  35. package/dist/src/utils/parse-resource.d.ts +6 -1
  36. package/dist/src/utils/parse-resource.d.ts.map +1 -1
  37. package/dist/src/utils/parse-resource.js +2 -2
  38. package/dist/src/utils/parse-resource.js.map +1 -1
  39. package/dist/src/utils/parse-url-string.d.ts +10 -3
  40. package/dist/src/utils/parse-url-string.d.ts.map +1 -1
  41. package/dist/src/utils/parse-url-string.js +8 -4
  42. package/dist/src/utils/parse-url-string.js.map +1 -1
  43. package/dist/src/utils/responses.d.ts +5 -0
  44. package/dist/src/utils/responses.d.ts.map +1 -0
  45. package/dist/src/utils/responses.js +27 -0
  46. package/dist/src/utils/responses.js.map +1 -0
  47. package/dist/src/utils/select-output-type.d.ts +12 -0
  48. package/dist/src/utils/select-output-type.d.ts.map +1 -0
  49. package/dist/src/utils/select-output-type.js +148 -0
  50. package/dist/src/utils/select-output-type.js.map +1 -0
  51. package/dist/src/utils/walk-path.d.ts +2 -1
  52. package/dist/src/utils/walk-path.d.ts.map +1 -1
  53. package/dist/src/utils/walk-path.js +1 -3
  54. package/dist/src/utils/walk-path.js.map +1 -1
  55. package/dist/src/verified-fetch.d.ts +24 -27
  56. package/dist/src/verified-fetch.d.ts.map +1 -1
  57. package/dist/src/verified-fetch.js +297 -150
  58. package/dist/src/verified-fetch.js.map +1 -1
  59. package/dist/typedoc-urls.json +25 -18
  60. package/package.json +58 -116
  61. package/src/index.ts +391 -72
  62. package/src/singleton.ts +20 -0
  63. package/src/types.ts +1 -0
  64. package/src/utils/dag-cbor-to-safe-json.ts +27 -0
  65. package/src/utils/get-content-disposition-filename.ts +18 -0
  66. package/src/utils/get-e-tag.ts +36 -0
  67. package/src/utils/{get-stream-and-content-type.ts → get-stream-from-async-iterable.ts} +10 -9
  68. package/src/utils/get-tar-stream.ts +68 -0
  69. package/src/utils/parse-url-string.ts +17 -3
  70. package/src/utils/responses.ts +29 -0
  71. package/src/utils/select-output-type.ts +167 -0
  72. package/src/utils/walk-path.ts +4 -5
  73. package/src/verified-fetch.ts +340 -153
  74. package/dist/src/utils/get-content-type.d.ts +0 -11
  75. package/dist/src/utils/get-content-type.d.ts.map +0 -1
  76. package/dist/src/utils/get-content-type.js +0 -43
  77. package/dist/src/utils/get-content-type.js.map +0 -1
  78. package/dist/src/utils/get-stream-and-content-type.d.ts +0 -9
  79. package/dist/src/utils/get-stream-and-content-type.d.ts.map +0 -1
  80. package/dist/src/utils/get-stream-and-content-type.js.map +0 -1
  81. package/src/utils/get-content-type.ts +0 -55
@@ -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'
13
- import { getStreamAndContentType } from './utils/get-stream-and-content-type.js'
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'
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'
16
- import type { CIDDetail, Resource, VerifiedFetchInit as VerifiedFetchOptions } from './index.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'
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,25 +36,31 @@ 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
  /**
33
42
  * Potential future options for the VerifiedFetch constructor.
34
43
  */
35
- // eslint-disable-next-line @typescript-eslint/no-empty-interface
36
44
  interface VerifiedFetchInit {
37
-
45
+ contentTypeParser?: ContentTypeParser
46
+ dnsResolvers?: DNSResolver[]
38
47
  }
39
48
 
40
49
  interface FetchHandlerFunctionArg {
41
50
  cid: CID
42
51
  path: string
43
- terminalElement?: UnixFSEntry
44
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
45
64
  }
46
65
 
47
66
  interface FetchHandlerFunction {
@@ -63,88 +82,212 @@ function convertOptions (options?: VerifiedFetchOptions): (Omit<VerifiedFetchOpt
63
82
  }
64
83
  }
65
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
+
66
119
  export class VerifiedFetch {
67
120
  private readonly helia: Helia
68
121
  private readonly ipns: IPNS
69
122
  private readonly unixfs: HeliaUnixFs
70
- private readonly dagJson: DAGJSON
71
- private readonly dagCbor: DAGCBOR
72
- private readonly json: JSON
73
- private readonly pathWalker: PathWalkerFn
74
123
  private readonly log: Logger
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
136
+ this.contentTypeParser = init?.contentTypeParser
90
137
  this.log.trace('created VerifiedFetch instance')
91
138
  }
92
139
 
93
- // handle vnd.ipfs.ipns-record
94
- private async handleIPNSRecord ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
95
- const response = new Response('vnd.ipfs.ipns-record support is not implemented', { status: 501 })
96
- 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
+
97
173
  return response
98
174
  }
99
175
 
100
- // handle vnd.ipld.car
101
- private async handleIPLDCar ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
102
- const response = new Response('vnd.ipld.car support is not implemented', { status: 501 })
103
- 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
+
104
187
  return response
105
188
  }
106
189
 
107
- private async handleDagJson ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
108
- this.log.trace('fetching %c/%s', cid, path)
109
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: cid.toString(), path }))
110
- const result = await this.dagJson.get(cid, {
111
- signal: options?.signal,
112
- onProgress: options?.onProgress
113
- })
114
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: cid.toString(), path }))
115
- const response = new Response(JSON.stringify(result), { status: 200 })
116
- 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
+
117
204
  return response
118
205
  }
119
206
 
120
- private async handleJson ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
207
+ private async handleJson ({ cid, path, accept, options }: FetchHandlerFunctionArg): Promise<Response> {
121
208
  this.log.trace('fetching %c/%s', cid, path)
122
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: cid.toString(), path }))
123
- const result: Record<any, any> = await this.json.get(cid, {
124
- signal: options?.signal,
125
- onProgress: options?.onProgress
126
- })
127
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: cid.toString(), path }))
128
- const response = new Response(JSON.stringify(result), { status: 200 })
129
- 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')
130
230
  return response
131
231
  }
132
232
 
133
- private async handleDagCbor ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
233
+ private async handleDagCbor ({ cid, path, accept, options }: FetchHandlerFunctionArg): Promise<Response> {
134
234
  this.log.trace('fetching %c/%s', cid, path)
135
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: cid.toString(), path }))
136
- const result = await this.dagCbor.get(cid, {
137
- signal: options?.signal,
138
- onProgress: options?.onProgress
139
- })
140
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: cid.toString(), path }))
141
- const response = new Response(JSON.stringify(result), { status: 200 })
142
- response.headers.set('content-type', 'application/json')
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
+
143
276
  return response
144
277
  }
145
278
 
146
- private async handleDagPb ({ cid, path, options, terminalElement }: FetchHandlerFunctionArg): Promise<Response> {
147
- 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
+
148
291
  let resolvedCID = terminalElement?.cid ?? cid
149
292
  let stat: UnixFSStats
150
293
  if (terminalElement?.type === 'directory') {
@@ -153,7 +296,6 @@ export class VerifiedFetch {
153
296
  const rootFilePath = 'index.html'
154
297
  try {
155
298
  this.log.trace('found directory at %c/%s, looking for index.html', cid, path)
156
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: dirCid.toString(), path: rootFilePath }))
157
299
  stat = await this.unixfs.stat(dirCid, {
158
300
  path: rootFilePath,
159
301
  signal: options?.signal,
@@ -165,144 +307,185 @@ export class VerifiedFetch {
165
307
  // terminalElement = stat
166
308
  } catch (err: any) {
167
309
  this.log('error loading path %c/%s', dirCid, rootFilePath, err)
168
- 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')
169
311
  } finally {
170
- 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 }))
171
313
  }
172
314
  }
173
315
 
174
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: resolvedCID.toString(), path: '' }))
175
316
  const asyncIter = this.unixfs.cat(resolvedCID, {
176
317
  signal: options?.signal,
177
318
  onProgress: options?.onProgress
178
319
  })
179
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: resolvedCID.toString(), path: '' }))
180
320
  this.log('got async iterator for %c/%s', cid, path)
181
321
 
182
- const { contentType, stream } = await getStreamAndContentType(asyncIter, path ?? '', this.helia.logger, {
322
+ const { stream, firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
183
323
  onProgress: options?.onProgress
184
324
  })
185
- const response = new Response(stream, { status: 200 })
186
- response.headers.set('content-type', contentType)
325
+ const response = okResponse(stream)
326
+ await this.setContentType(firstChunk, path, response)
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
+ }
187
331
 
188
332
  return response
189
333
  }
190
334
 
191
335
  private async handleRaw ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
192
- this.log.trace('fetching %c/%s', cid, path)
193
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: cid.toString(), path }))
194
- const result = await this.helia.blockstore.get(cid)
195
- options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: cid.toString(), path }))
196
- const response = new Response(decode(result), { status: 200 })
197
- response.headers.set('content-type', 'application/octet-stream')
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
+
198
349
  return response
199
350
  }
200
351
 
201
- /**
202
- * Determines the format requested by the client, defaults to `null` if no format is requested.
203
- *
204
- * @see https://specs.ipfs.tech/http-gateways/path-gateway/#format-request-query-parameter
205
- * @default 'raw'
206
- */
207
- private getFormat ({ headerFormat, queryFormat }: { headerFormat: string | null, queryFormat: string | null }): string | null {
208
- const formatMap: Record<string, string> = {
209
- 'vnd.ipld.raw': 'raw',
210
- 'vnd.ipld.car': 'car',
211
- 'application/x-tar': 'tar',
212
- 'application/vnd.ipld.dag-json': 'dag-json',
213
- 'application/vnd.ipld.dag-cbor': 'dag-cbor',
214
- 'application/json': 'json',
215
- 'application/cbor': 'cbor',
216
- 'vnd.ipfs.ipns-record': 'ipns-record'
217
- }
352
+ private async setContentType (bytes: Uint8Array, path: string, response: Response): Promise<void> {
353
+ let contentType = 'application/octet-stream'
218
354
 
219
- if (headerFormat != null) {
220
- for (const format in formatMap) {
221
- if (headerFormat.includes(format)) {
222
- return formatMap[format]
355
+ if (this.contentTypeParser != null) {
356
+ try {
357
+ let fileName = path.split('/').pop()?.trim()
358
+ fileName = fileName === '' ? undefined : fileName
359
+ const parsed = this.contentTypeParser(bytes, fileName)
360
+
361
+ if (isPromise(parsed)) {
362
+ const result = await parsed
363
+
364
+ if (result != null) {
365
+ contentType = result
366
+ }
367
+ } else if (parsed != null) {
368
+ contentType = parsed
223
369
  }
370
+ } catch (err) {
371
+ this.log.error('Error parsing content type', err)
224
372
  }
225
- } else if (queryFormat != null) {
226
- return queryFormat
227
373
  }
228
374
 
229
- return null
375
+ response.headers.set('content-type', contentType)
230
376
  }
231
377
 
232
378
  /**
233
- * Map of format to specific handlers for that format.
234
- * 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.
235
381
  */
236
- private readonly formatHandlers: Record<string, FetchHandlerFunction> = {
237
- raw: async () => new Response('application/vnd.ipld.raw support is not implemented', { status: 501 }),
238
- car: this.handleIPLDCar,
239
- 'ipns-record': this.handleIPNSRecord,
240
- tar: async () => new Response('application/x-tar support is not implemented', { status: 501 }),
241
- 'dag-json': async () => new Response('application/vnd.ipld.dag-json support is not implemented', { status: 501 }),
242
- 'dag-cbor': async () => new Response('application/vnd.ipld.dag-cbor support is not implemented', { status: 501 }),
243
- json: async () => new Response('application/json support is not implemented', { status: 501 }),
244
- cbor: async () => new Response('application/cbor support is not implemented', { status: 501 })
245
- }
246
-
247
382
  private readonly codecHandlers: Record<number, FetchHandlerFunction> = {
248
- [dagJsonCode]: this.handleDagJson,
249
383
  [dagPbCode]: this.handleDagPb,
384
+ [ipldDagJson.code]: this.handleJson,
250
385
  [jsonCode]: this.handleJson,
251
- [dagCborCode]: this.handleDagCbor,
252
- [rawCode]: this.handleRaw
386
+ [ipldDagCbor.code]: this.handleDagCbor,
387
+ [rawCode]: this.handleRaw,
388
+ [identity.code]: this.handleRaw
253
389
  }
254
390
 
255
391
  async fetch (resource: Resource, opts?: VerifiedFetchOptions): Promise<Response> {
392
+ this.log('fetch %s', resource)
393
+
256
394
  const options = convertOptions(opts)
257
- const { path, query, ...rest } = await parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, options)
258
- const cid = rest.cid
259
- let response: Response | undefined
260
395
 
261
- 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 }))
262
397
 
263
- if (format != null) {
264
- // TODO: These should be handled last when they're returning something other than 501
265
- 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)
266
400
 
267
- if (formatHandler != null) {
268
- response = await formatHandler.call(this, { cid, path, options })
401
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:resolve', { cid, path }))
269
402
 
270
- if (response.status === 501) {
271
- return response
272
- }
273
- }
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)
274
408
  }
275
409
 
276
- let terminalElement: UnixFSEntry | undefined
277
- let ipfsRoots: string | undefined
410
+ const queryFormatMapping = queryFormatToAcceptHeader(query.format)
278
411
 
279
- try {
280
- const pathDetails = await this.pathWalker(this.helia.blockstore, `${cid.toString()}/${path}`, options)
281
- ipfsRoots = pathDetails.ipfsRoots.join(',')
282
- terminalElement = pathDetails.terminalElement
283
- } catch (err) {
284
- this.log.error('Error walking path %s', path, err)
285
- // 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)
286
414
  }
287
415
 
288
- if (response == null) {
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()
422
+ }
423
+
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
289
453
  const codecHandler = this.codecHandlers[cid.code]
290
454
 
291
- if (codecHandler != null) {
292
- response = await codecHandler.call(this, { cid, path, options, terminalElement })
293
- } else {
294
- 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`)
295
457
  }
458
+
459
+ response = await codecHandler.call(this, handlerArgs)
296
460
  }
297
461
 
298
- response.headers.set('etag', cid.toString()) // https://specs.ipfs.tech/http-gateways/path-gateway/#etag-response-header
299
- response.headers.set('cache-cotrol', 'public, max-age=29030400, immutable')
300
- 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())
301
466
 
302
- if (ipfsRoots != null) {
303
- response.headers.set('X-Ipfs-Roots', ipfsRoots) // 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)
304
486
  }
305
- // 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 }))
306
489
 
307
490
  return response
308
491
  }
@@ -321,3 +504,7 @@ export class VerifiedFetch {
321
504
  await this.helia.stop()
322
505
  }
323
506
  }
507
+
508
+ function isPromise <T> (p?: any): p is Promise<T> {
509
+ return p?.then != null
510
+ }
@@ -1,11 +0,0 @@
1
- interface TestInput {
2
- bytes: Uint8Array;
3
- path: string;
4
- }
5
- export declare const DEFAULT_MIME_TYPE = "application/octet-stream";
6
- /**
7
- * Get the content type from the input based on the tests.
8
- */
9
- export declare function getContentType(input: TestInput): Promise<string>;
10
- export {};
11
- //# sourceMappingURL=get-content-type.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"get-content-type.d.ts","sourceRoot":"","sources":["../../../src/utils/get-content-type.ts"],"names":[],"mappings":"AAEA,UAAU,SAAS;IACjB,KAAK,EAAE,UAAU,CAAA;IACjB,IAAI,EAAE,MAAM,CAAA;CACb;AAID,eAAO,MAAM,iBAAiB,6BAA6B,CAAA;AAkC3D;;GAEG;AACH,wBAAsB,cAAc,CAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,CAQvE"}