@helia/verified-fetch 1.3.5 → 1.3.7

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 (31) hide show
  1. package/dist/index.min.js +7 -7
  2. package/dist/src/utils/get-resolved-accept-header.d.ts +9 -0
  3. package/dist/src/utils/get-resolved-accept-header.d.ts.map +1 -0
  4. package/dist/src/utils/get-resolved-accept-header.js +27 -0
  5. package/dist/src/utils/get-resolved-accept-header.js.map +1 -0
  6. package/dist/src/utils/is-accept-explicit.d.ts +13 -0
  7. package/dist/src/utils/is-accept-explicit.d.ts.map +1 -0
  8. package/dist/src/utils/is-accept-explicit.js +23 -0
  9. package/dist/src/utils/is-accept-explicit.js.map +1 -0
  10. package/dist/src/utils/responses.d.ts +1 -0
  11. package/dist/src/utils/responses.d.ts.map +1 -1
  12. package/dist/src/utils/responses.js +10 -0
  13. package/dist/src/utils/responses.js.map +1 -1
  14. package/dist/src/utils/select-output-type.d.ts +1 -0
  15. package/dist/src/utils/select-output-type.d.ts.map +1 -1
  16. package/dist/src/utils/select-output-type.js +2 -1
  17. package/dist/src/utils/select-output-type.js.map +1 -1
  18. package/dist/src/utils/walk-path.d.ts +2 -1
  19. package/dist/src/utils/walk-path.d.ts.map +1 -1
  20. package/dist/src/utils/walk-path.js +5 -1
  21. package/dist/src/utils/walk-path.js.map +1 -1
  22. package/dist/src/verified-fetch.d.ts.map +1 -1
  23. package/dist/src/verified-fetch.js +44 -31
  24. package/dist/src/verified-fetch.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/utils/get-resolved-accept-header.ts +42 -0
  27. package/src/utils/is-accept-explicit.ts +31 -0
  28. package/src/utils/responses.ts +13 -0
  29. package/src/utils/select-output-type.ts +2 -1
  30. package/src/utils/walk-path.ts +7 -2
  31. package/src/verified-fetch.ts +48 -36
@@ -85,6 +85,19 @@ export function notAcceptableResponse (url: string, body?: SupportedBodyTypes, i
85
85
  return response
86
86
  }
87
87
 
88
+ export function notFoundResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response {
89
+ const response = new Response(body, {
90
+ ...(init ?? {}),
91
+ status: 404,
92
+ statusText: 'Not Found'
93
+ })
94
+
95
+ setType(response, 'basic')
96
+ setUrl(response, url)
97
+
98
+ return response
99
+ }
100
+
88
101
  /**
89
102
  * if body is an Error, it will be converted to a string containing the error message.
90
103
  */
@@ -55,6 +55,7 @@ const CID_TYPE_MAP: Record<number, string[]> = {
55
55
  'application/octet-stream',
56
56
  'application/vnd.ipld.raw',
57
57
  'application/vnd.ipfs.ipns-record',
58
+ 'application/vnd.ipld.dag-json',
58
59
  'application/vnd.ipld.car',
59
60
  'application/x-tar'
60
61
  ]
@@ -145,7 +146,7 @@ function parseQFactor (str?: string): number {
145
146
  return factor
146
147
  }
147
148
 
148
- const FORMAT_TO_MIME_TYPE: Record<RequestFormatShorthand, string> = {
149
+ export const FORMAT_TO_MIME_TYPE: Record<RequestFormatShorthand, string> = {
149
150
  raw: 'application/vnd.ipld.raw',
150
151
  car: 'application/vnd.ipld.car',
151
152
  'dag-json': 'application/vnd.ipld.dag-json',
@@ -1,4 +1,5 @@
1
- import { walkPath as exporterWalk, type ExporterOptions, type ReadableStorage, type UnixFSEntry } from 'ipfs-unixfs-exporter'
1
+ import { CodeError } from '@libp2p/interface'
2
+ import { walkPath as exporterWalk, type ExporterOptions, type ReadableStorage, type ObjectNode, type UnixFSEntry } from 'ipfs-unixfs-exporter'
2
3
  import type { CID } from 'multiformats/cid'
3
4
 
4
5
  export interface PathWalkerOptions extends ExporterOptions {
@@ -24,7 +25,7 @@ export async function walkPath (blockstore: ReadableStorage, path: string, optio
24
25
  }
25
26
 
26
27
  if (terminalElement == null) {
27
- throw new Error('No terminal element found')
28
+ throw new CodeError('No terminal element found', 'ERR_NO_TERMINAL_ELEMENT')
28
29
  }
29
30
 
30
31
  return {
@@ -32,3 +33,7 @@ export async function walkPath (blockstore: ReadableStorage, path: string, optio
32
33
  terminalElement
33
34
  }
34
35
  }
36
+
37
+ export function isObjectNode (node: UnixFSEntry): node is ObjectNode {
38
+ return node.type === 'object'
39
+ }
@@ -4,7 +4,7 @@ import { unixfs as heliaUnixFs, type UnixFS as HeliaUnixFs } from '@helia/unixfs
4
4
  import * as ipldDagCbor from '@ipld/dag-cbor'
5
5
  import * as ipldDagJson from '@ipld/dag-json'
6
6
  import { code as dagPbCode } from '@ipld/dag-pb'
7
- import { AbortError, type AbortOptions, type Logger, type PeerId } from '@libp2p/interface'
7
+ import { type AbortOptions, type Logger, type PeerId } from '@libp2p/interface'
8
8
  import { Record as DHTRecord } from '@libp2p/kad-dht'
9
9
  import { peerIdFromString } from '@libp2p/peer-id'
10
10
  import { Key } from 'interface-datastore'
@@ -20,19 +20,20 @@ import { ByteRangeContext } from './utils/byte-range-context.js'
20
20
  import { dagCborToSafeJSON } from './utils/dag-cbor-to-safe-json.js'
21
21
  import { getContentDispositionFilename } from './utils/get-content-disposition-filename.js'
22
22
  import { getETag } from './utils/get-e-tag.js'
23
+ import { getResolvedAcceptHeader } from './utils/get-resolved-accept-header.js'
23
24
  import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterable.js'
24
25
  import { tarStream } from './utils/get-tar-stream.js'
25
26
  import { parseResource } from './utils/parse-resource.js'
26
27
  import { setCacheControlHeader } from './utils/response-headers.js'
27
- import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse, badRangeResponse, okRangeResponse, badGatewayResponse } from './utils/responses.js'
28
- import { selectOutputType, queryFormatToAcceptHeader } from './utils/select-output-type.js'
29
- import { walkPath } from './utils/walk-path.js'
28
+ import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse, badRangeResponse, okRangeResponse, badGatewayResponse, notFoundResponse } from './utils/responses.js'
29
+ import { selectOutputType } from './utils/select-output-type.js'
30
+ import { isObjectNode, walkPath } from './utils/walk-path.js'
30
31
  import type { CIDDetail, ContentTypeParser, Resource, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
31
32
  import type { RequestFormatShorthand } from './types.js'
32
33
  import type { ParsedUrlStringResults } from './utils/parse-url-string'
33
34
  import type { Helia } from '@helia/interface'
34
35
  import type { DNSResolver } from '@multiformats/dns/resolvers'
35
- import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
36
+ import type { ObjectNode, UnixFSEntry } from 'ipfs-unixfs-exporter'
36
37
  import type { CID } from 'multiformats/cid'
37
38
 
38
39
  interface VerifiedFetchComponents {
@@ -93,6 +94,7 @@ function convertOptions (options?: VerifiedFetchOptions): (Omit<VerifiedFetchOpt
93
94
  * skipped and set to these values.
94
95
  */
95
96
  const RAW_HEADERS = [
97
+ 'application/vnd.ipld.dag-json',
96
98
  'application/vnd.ipld.raw',
97
99
  'application/octet-stream'
98
100
  ]
@@ -103,8 +105,9 @@ const RAW_HEADERS = [
103
105
  * type. This avoids the user from receiving something different when they
104
106
  * signal that they want to `Accept` a specific mime type.
105
107
  */
106
- function getOverridenRawContentType (headers?: HeadersInit): string | undefined {
107
- const acceptHeader = new Headers(headers).get('accept') ?? ''
108
+ function getOverridenRawContentType ({ headers, accept }: { headers?: HeadersInit, accept?: string }): string | undefined {
109
+ // accept has already been resolved by getResolvedAcceptHeader, if we have it, use it.
110
+ const acceptHeader = accept ?? new Headers(headers).get('accept') ?? ''
108
111
 
109
112
  // e.g. "Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8"
110
113
  const acceptHeaders = acceptHeader.split(',')
@@ -233,8 +236,31 @@ export class VerifiedFetch {
233
236
 
234
237
  private async handleDagCbor ({ resource, cid, path, accept, options }: FetchHandlerFunctionArg): Promise<Response> {
235
238
  this.log.trace('fetching %c/%s', cid, path)
239
+ let terminalElement: ObjectNode | undefined
240
+ let ipfsRoots: CID[] | undefined
241
+
242
+ // need to walk path, if it exists, to get the terminal element
243
+ try {
244
+ const pathDetails = await walkPath(this.helia.blockstore, `${cid.toString()}/${path}`, options)
245
+ ipfsRoots = pathDetails.ipfsRoots
246
+ const potentialTerminalElement = pathDetails.terminalElement
247
+ if (potentialTerminalElement == null) {
248
+ return notFoundResponse(resource)
249
+ }
250
+ if (isObjectNode(potentialTerminalElement)) {
251
+ terminalElement = potentialTerminalElement
252
+ }
253
+ } catch (err: any) {
254
+ options?.signal?.throwIfAborted()
255
+ if (['ERR_NO_PROP', 'ERR_NO_TERMINAL_ELEMENT'].includes(err.code)) {
256
+ return notFoundResponse(resource)
257
+ }
258
+
259
+ this.log.error('error walking path %s', path, err)
260
+ return badGatewayResponse(resource, 'Error walking path')
261
+ }
262
+ const block = terminalElement?.node ?? await this.helia.blockstore.get(cid, options)
236
263
 
237
- const block = await this.helia.blockstore.get(cid, options)
238
264
  let body: string | Uint8Array
239
265
 
240
266
  if (accept === 'application/octet-stream' || accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
@@ -274,6 +300,10 @@ export class VerifiedFetch {
274
300
 
275
301
  response.headers.set('content-type', accept)
276
302
 
303
+ if (ipfsRoots != null) {
304
+ 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
305
+ }
306
+
277
307
  return response
278
308
  }
279
309
 
@@ -288,8 +318,9 @@ export class VerifiedFetch {
288
318
  ipfsRoots = pathDetails.ipfsRoots
289
319
  terminalElement = pathDetails.terminalElement
290
320
  } catch (err: any) {
291
- if (options?.signal?.aborted === true) {
292
- throw new AbortError('signal aborted by user')
321
+ options?.signal?.throwIfAborted()
322
+ if (['ERR_NO_PROP', 'ERR_NO_TERMINAL_ELEMENT'].includes(err.code)) {
323
+ return notFoundResponse(resource.toString())
293
324
  }
294
325
  this.log.error('error walking path %s', path, err)
295
326
 
@@ -328,9 +359,7 @@ export class VerifiedFetch {
328
359
  path = rootFilePath
329
360
  resolvedCID = stat.cid
330
361
  } catch (err: any) {
331
- if (options?.signal?.aborted === true) {
332
- throw new AbortError('signal aborted by user')
333
- }
362
+ options?.signal?.throwIfAborted()
334
363
  this.log('error loading path %c/%s', dirCid, rootFilePath, err)
335
364
  return notSupportedResponse('Unable to find index.html for directory at given path. Support for directories with implicit root is not implemented')
336
365
  } finally {
@@ -374,9 +403,7 @@ export class VerifiedFetch {
374
403
 
375
404
  return response
376
405
  } catch (err: any) {
377
- if (options?.signal?.aborted === true) {
378
- throw new AbortError('signal aborted by user')
379
- }
406
+ options?.signal?.throwIfAborted()
380
407
  this.log.error('error streaming %c/%s', cid, path, err)
381
408
  if (byteRangeContext.isRangeRequest && err.code === 'ERR_INVALID_PARAMS') {
382
409
  return badRangeResponse(resource)
@@ -385,7 +412,7 @@ export class VerifiedFetch {
385
412
  }
386
413
  }
387
414
 
388
- private async handleRaw ({ resource, cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
415
+ private async handleRaw ({ resource, cid, path, options, accept }: FetchHandlerFunctionArg): Promise<Response> {
389
416
  const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers)
390
417
  const result = await this.helia.blockstore.get(cid, options)
391
418
  byteRangeContext.setBody(result)
@@ -396,7 +423,7 @@ export class VerifiedFetch {
396
423
  // if the user has specified an `Accept` header that corresponds to a raw
397
424
  // type, honour that header, so for example they don't request
398
425
  // `application/vnd.ipld.raw` but get `application/octet-stream`
399
- const overriddenContentType = getOverridenRawContentType(options?.headers)
426
+ const overriddenContentType = getOverridenRawContentType({ headers: options?.headers, accept })
400
427
  if (overriddenContentType != null) {
401
428
  response.headers.set('content-type', overriddenContentType)
402
429
  } else {
@@ -452,7 +479,6 @@ export class VerifiedFetch {
452
479
  * TODO: move operations called by fetch to a queue of operations where we can
453
480
  * always exit early (and cleanly) if a given signal is aborted
454
481
  */
455
- // eslint-disable-next-line complexity
456
482
  async fetch (resource: Resource, opts?: VerifiedFetchOptions): Promise<Response> {
457
483
  this.log('fetch %s', resource)
458
484
 
@@ -474,9 +500,7 @@ export class VerifiedFetch {
474
500
  ttl = result.ttl
475
501
  protocol = result.protocol
476
502
  } catch (err: any) {
477
- if (options?.signal?.aborted === true) {
478
- throw new AbortError('signal aborted by user')
479
- }
503
+ options?.signal?.throwIfAborted()
480
504
  this.log.error('error parsing resource %s', resource, err)
481
505
 
482
506
  return badRequestResponse(resource.toString(), err)
@@ -484,20 +508,8 @@ export class VerifiedFetch {
484
508
 
485
509
  options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:resolve', { cid, path }))
486
510
 
487
- const requestHeaders = new Headers(options?.headers)
488
- const incomingAcceptHeader = requestHeaders.get('accept')
489
-
490
- if (incomingAcceptHeader != null) {
491
- this.log('incoming accept header "%s"', incomingAcceptHeader)
492
- }
493
-
494
- const queryFormatMapping = queryFormatToAcceptHeader(query.format)
495
-
496
- if (query.format != null) {
497
- this.log('incoming query format "%s", mapped to %s', query.format, queryFormatMapping)
498
- }
511
+ const acceptHeader = getResolvedAcceptHeader({ query, headers: options?.headers, logger: this.helia.logger })
499
512
 
500
- const acceptHeader = incomingAcceptHeader ?? queryFormatMapping
501
513
  const accept = selectOutputType(cid, acceptHeader)
502
514
  this.log('output type %s', accept)
503
515
 
@@ -508,7 +520,7 @@ export class VerifiedFetch {
508
520
  let response: Response
509
521
  let reqFormat: RequestFormatShorthand | undefined
510
522
 
511
- const handlerArgs = { resource: resource.toString(), cid, path, accept, options }
523
+ const handlerArgs: FetchHandlerFunctionArg = { resource: resource.toString(), cid, path, accept, options }
512
524
 
513
525
  if (accept === 'application/vnd.ipfs.ipns-record') {
514
526
  // the user requested a raw IPNS record