@helia/verified-fetch 2.3.0 → 2.4.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 (47) hide show
  1. package/README.md +8 -0
  2. package/dist/index.min.js +28 -31
  3. package/dist/src/index.d.ts +22 -11
  4. package/dist/src/index.d.ts.map +1 -1
  5. package/dist/src/index.js +9 -0
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/types.d.ts +17 -0
  8. package/dist/src/types.d.ts.map +1 -1
  9. package/dist/src/utils/parse-resource.d.ts +2 -1
  10. package/dist/src/utils/parse-resource.d.ts.map +1 -1
  11. package/dist/src/utils/parse-resource.js +4 -3
  12. package/dist/src/utils/parse-resource.js.map +1 -1
  13. package/dist/src/utils/parse-url-string.d.ts +8 -3
  14. package/dist/src/utils/parse-url-string.d.ts.map +1 -1
  15. package/dist/src/utils/parse-url-string.js +30 -4
  16. package/dist/src/utils/parse-url-string.js.map +1 -1
  17. package/dist/src/utils/server-timing.d.ts +13 -0
  18. package/dist/src/utils/server-timing.d.ts.map +1 -0
  19. package/dist/src/utils/server-timing.js +19 -0
  20. package/dist/src/utils/server-timing.js.map +1 -0
  21. package/dist/src/utils/set-content-type.d.ts +12 -0
  22. package/dist/src/utils/set-content-type.d.ts.map +1 -0
  23. package/dist/src/utils/set-content-type.js +28 -0
  24. package/dist/src/utils/set-content-type.js.map +1 -0
  25. package/dist/src/utils/type-guards.d.ts +2 -0
  26. package/dist/src/utils/type-guards.d.ts.map +1 -0
  27. package/dist/src/utils/type-guards.js +4 -0
  28. package/dist/src/utils/type-guards.js.map +1 -0
  29. package/dist/src/utils/walk-path.d.ts +0 -1
  30. package/dist/src/utils/walk-path.d.ts.map +1 -1
  31. package/dist/src/utils/walk-path.js +1 -1
  32. package/dist/src/utils/walk-path.js.map +1 -1
  33. package/dist/src/verified-fetch.d.ts +9 -1
  34. package/dist/src/verified-fetch.d.ts.map +1 -1
  35. package/dist/src/verified-fetch.js +50 -46
  36. package/dist/src/verified-fetch.js.map +1 -1
  37. package/dist/typedoc-urls.json +0 -1
  38. package/package.json +2 -2
  39. package/src/index.ts +24 -11
  40. package/src/types.ts +19 -0
  41. package/src/utils/parse-resource.ts +5 -4
  42. package/src/utils/parse-url-string.ts +38 -7
  43. package/src/utils/server-timing.ts +37 -0
  44. package/src/utils/set-content-type.ts +38 -0
  45. package/src/utils/type-guards.ts +3 -0
  46. package/src/utils/walk-path.ts +1 -1
  47. package/src/verified-fetch.ts +59 -51
@@ -0,0 +1,38 @@
1
+ import { type Logger } from '@libp2p/interface'
2
+ import { type ContentTypeParser } from '../types.js'
3
+ import { isPromise } from './type-guards.js'
4
+
5
+ export interface SetContentTypeOptions {
6
+ bytes: Uint8Array
7
+ path: string
8
+ response: Response
9
+ defaultContentType?: string
10
+ contentTypeParser: ContentTypeParser | undefined
11
+ log: Logger
12
+ }
13
+
14
+ export async function setContentType ({ bytes, path, response, contentTypeParser, log, defaultContentType = 'application/octet-stream' }: SetContentTypeOptions): Promise<void> {
15
+ let contentType: string | undefined
16
+
17
+ if (contentTypeParser != null) {
18
+ try {
19
+ let fileName = path.split('/').pop()?.trim()
20
+ fileName = fileName === '' ? undefined : fileName
21
+ const parsed = contentTypeParser(bytes, fileName)
22
+
23
+ if (isPromise(parsed)) {
24
+ const result = await parsed
25
+
26
+ if (result != null) {
27
+ contentType = result
28
+ }
29
+ } else if (parsed != null) {
30
+ contentType = parsed
31
+ }
32
+ } catch (err) {
33
+ log.error('error parsing content type', err)
34
+ }
35
+ }
36
+ log.trace('setting content type to "%s"', contentType ?? defaultContentType)
37
+ response.headers.set('content-type', contentType ?? defaultContentType)
38
+ }
@@ -0,0 +1,3 @@
1
+ export function isPromise <T> (p?: any): p is Promise<T> {
2
+ return p?.then != null
3
+ }
@@ -19,7 +19,7 @@ export interface PathWalkerFn {
19
19
  (blockstore: ReadableStorage, path: string, options?: PathWalkerOptions): Promise<PathWalkerResponse>
20
20
  }
21
21
 
22
- export async function walkPath (blockstore: ReadableStorage, path: string, options?: PathWalkerOptions): Promise<PathWalkerResponse> {
22
+ async function walkPath (blockstore: ReadableStorage, path: string, options?: PathWalkerOptions): Promise<PathWalkerResponse> {
23
23
  const ipfsRoots: CID[] = []
24
24
  let terminalElement: UnixFSEntry | undefined
25
25
 
@@ -6,7 +6,7 @@ import { code as dagPbCode } from '@ipld/dag-pb'
6
6
  import { type AbortOptions, type Logger, type PeerId } from '@libp2p/interface'
7
7
  import { Record as DHTRecord } from '@libp2p/kad-dht'
8
8
  import { Key } from 'interface-datastore'
9
- import { exporter } from 'ipfs-unixfs-exporter'
9
+ import { exporter, type ObjectNode } from 'ipfs-unixfs-exporter'
10
10
  import toBrowserReadableStream from 'it-to-browser-readablestream'
11
11
  import { LRUCache } from 'lru-cache'
12
12
  import { type CID } from 'multiformats/cid'
@@ -32,12 +32,13 @@ import { resourceToSessionCacheKey } from './utils/resource-to-cache-key.js'
32
32
  import { setCacheControlHeader, setIpfsRoots } from './utils/response-headers.js'
33
33
  import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse, badRangeResponse, okRangeResponse, badGatewayResponse, notFoundResponse } from './utils/responses.js'
34
34
  import { selectOutputType } from './utils/select-output-type.js'
35
+ import { serverTiming } from './utils/server-timing.js'
36
+ import { setContentType } from './utils/set-content-type.js'
35
37
  import { handlePathWalking, isObjectNode } from './utils/walk-path.js'
36
38
  import type { CIDDetail, ContentTypeParser, CreateVerifiedFetchOptions, Resource, ResourceDetail, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
37
39
  import type { FetchHandlerFunctionArg, RequestFormatShorthand } from './types.js'
38
40
  import type { Helia, SessionBlockstore } from '@helia/interface'
39
41
  import type { Blockstore } from 'interface-blockstore'
40
- import type { ObjectNode } from 'ipfs-unixfs-exporter'
41
42
 
42
43
  const SESSION_CACHE_MAX_SIZE = 100
43
44
  const SESSION_CACHE_TTL_MS = 60 * 1000
@@ -111,6 +112,8 @@ export class VerifiedFetch {
111
112
  private readonly log: Logger
112
113
  private readonly contentTypeParser: ContentTypeParser | undefined
113
114
  private readonly blockstoreSessions: LRUCache<string, SessionBlockstore>
115
+ private serverTimingHeaders: string[] = []
116
+ private readonly withServerTiming: boolean
114
117
 
115
118
  constructor ({ helia, ipns }: VerifiedFetchComponents, init?: CreateVerifiedFetchOptions) {
116
119
  this.helia = helia
@@ -124,6 +127,7 @@ export class VerifiedFetch {
124
127
  store.close()
125
128
  }
126
129
  })
130
+ this.withServerTiming = init?.withServerTiming ?? false
127
131
  this.log.trace('created VerifiedFetch instance')
128
132
  }
129
133
 
@@ -248,13 +252,14 @@ export class VerifiedFetch {
248
252
  return response
249
253
  }
250
254
 
251
- private async handleDagCbor ({ resource, cid, path, accept, session, options }: FetchHandlerFunctionArg): Promise<Response> {
255
+ private async handleDagCbor ({ resource, cid, path, accept, session, options, withServerTiming }: FetchHandlerFunctionArg): Promise<Response> {
252
256
  this.log.trace('fetching %c/%s', cid, path)
253
257
  let terminalElement: ObjectNode
254
258
  const blockstore = this.getBlockstore(cid, resource, session, options)
255
259
 
256
260
  // need to walk path, if it exists, to get the terminal element
257
- const pathDetails = await handlePathWalking({ cid, path, resource, options, blockstore, log: this.log })
261
+ const pathDetails = await this.handleServerTiming('path-walking', '', async () => handlePathWalking({ cid, path, resource, options, blockstore, log: this.log, withServerTiming }), withServerTiming)
262
+
258
263
  if (pathDetails instanceof Response) {
259
264
  return pathDetails
260
265
  }
@@ -312,11 +317,12 @@ export class VerifiedFetch {
312
317
  return response
313
318
  }
314
319
 
315
- private async handleDagPb ({ cid, path, resource, session, options }: FetchHandlerFunctionArg): Promise<Response> {
320
+ private async handleDagPb ({ cid, path, resource, session, options, withServerTiming }: FetchHandlerFunctionArg): Promise<Response> {
316
321
  let redirected = false
317
322
  const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers)
318
323
  const blockstore = this.getBlockstore(cid, resource, session, options)
319
- const pathDetails = await handlePathWalking({ cid, path, resource, options, blockstore, log: this.log })
324
+ const pathDetails = await this.handleServerTiming('path-walking', '', async () => handlePathWalking({ cid, path, resource, options, blockstore, log: this.log, withServerTiming }), withServerTiming)
325
+
320
326
  if (pathDetails instanceof Response) {
321
327
  return pathDetails
322
328
  }
@@ -347,10 +353,10 @@ export class VerifiedFetch {
347
353
  try {
348
354
  this.log.trace('found directory at %c/%s, looking for index.html', cid, path)
349
355
 
350
- const entry = await exporter(`/ipfs/${dirCid}/${rootFilePath}`, this.helia.blockstore, {
356
+ const entry = await this.handleServerTiming('exporter-dir', '', async () => exporter(`/ipfs/${dirCid}/${rootFilePath}`, this.helia.blockstore, {
351
357
  signal: options?.signal,
352
358
  onProgress: options?.onProgress
353
- })
359
+ }), withServerTiming)
354
360
 
355
361
  this.log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, entry.cid)
356
362
  path = rootFilePath
@@ -375,10 +381,10 @@ export class VerifiedFetch {
375
381
  this.log.trace('calling exporter for %c/%s with offset=%o & length=%o', resolvedCID, path, offset, length)
376
382
 
377
383
  try {
378
- const entry = await exporter(resolvedCID, this.helia.blockstore, {
384
+ const entry = await this.handleServerTiming('exporter-file', '', async () => exporter(resolvedCID, this.helia.blockstore, {
379
385
  signal: options?.signal,
380
386
  onProgress: options?.onProgress
381
- })
387
+ }), withServerTiming)
382
388
 
383
389
  const asyncIter = entry.content({
384
390
  signal: options?.signal,
@@ -388,17 +394,19 @@ export class VerifiedFetch {
388
394
  })
389
395
  this.log('got async iterator for %c/%s', cid, path)
390
396
 
391
- const { stream, firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
397
+ const { stream, firstChunk } = await this.handleServerTiming('stream-and-chunk', '', async () => getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
392
398
  onProgress: options?.onProgress,
393
399
  signal: options?.signal
394
- })
400
+ }), withServerTiming)
401
+
395
402
  byteRangeContext.setBody(stream)
396
403
  // if not a valid range request, okRangeRequest will call okResponse
397
404
  const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log: this.log }, {
398
405
  redirected
399
406
  })
400
407
 
401
- await this.setContentType(firstChunk, path, response)
408
+ await this.handleServerTiming('set-content-type', '', async () => setContentType({ bytes: firstChunk, path, response, contentTypeParser: this.contentTypeParser, log: this.log }), withServerTiming)
409
+
402
410
  setIpfsRoots(response, ipfsRoots)
403
411
 
404
412
  return response
@@ -434,37 +442,11 @@ export class VerifiedFetch {
434
442
  // if the user has specified an `Accept` header that corresponds to a raw
435
443
  // type, honour that header, so for example they don't request
436
444
  // `application/vnd.ipld.raw` but get `application/octet-stream`
437
- await this.setContentType(result, path, response, getOverridenRawContentType({ headers: options?.headers, accept }))
445
+ await setContentType({ bytes: result, path, response, defaultContentType: getOverridenRawContentType({ headers: options?.headers, accept }), contentTypeParser: this.contentTypeParser, log: this.log })
438
446
 
439
447
  return response
440
448
  }
441
449
 
442
- private async setContentType (bytes: Uint8Array, path: string, response: Response, defaultContentType = 'application/octet-stream'): Promise<void> {
443
- let contentType: string | undefined
444
-
445
- if (this.contentTypeParser != null) {
446
- try {
447
- let fileName = path.split('/').pop()?.trim()
448
- fileName = fileName === '' ? undefined : fileName
449
- const parsed = this.contentTypeParser(bytes, fileName)
450
-
451
- if (isPromise(parsed)) {
452
- const result = await parsed
453
-
454
- if (result != null) {
455
- contentType = result
456
- }
457
- } else if (parsed != null) {
458
- contentType = parsed
459
- }
460
- } catch (err) {
461
- this.log.error('error parsing content type', err)
462
- }
463
- }
464
- this.log.trace('setting content type to "%s"', contentType ?? defaultContentType)
465
- response.headers.set('content-type', contentType ?? defaultContentType)
466
- }
467
-
468
450
  /**
469
451
  * If the user has not specified an Accept header or format query string arg,
470
452
  * use the CID codec to choose an appropriate handler for the block data.
@@ -478,6 +460,34 @@ export class VerifiedFetch {
478
460
  [identity.code]: this.handleRaw
479
461
  }
480
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
+ }
487
+
488
+ return response
489
+ }
490
+
481
491
  /**
482
492
  * We're starting to get to the point where we need a queue or pipeline of
483
493
  * operations to perform and a single place to handle errors.
@@ -489,6 +499,7 @@ export class VerifiedFetch {
489
499
  this.log('fetch %s', resource)
490
500
 
491
501
  const options = convertOptions(opts)
502
+ const withServerTiming = options?.withServerTiming ?? this.withServerTiming
492
503
 
493
504
  options?.onProgress?.(new CustomProgressEvent<ResourceDetail>('verified-fetch:request:start', { resource }))
494
505
  // resolve the CID/path from the requested resource
@@ -499,18 +510,19 @@ export class VerifiedFetch {
499
510
  let protocol: ParsedUrlStringResults['protocol']
500
511
  let ipfsPath: string
501
512
  try {
502
- const result = await parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, options)
513
+ const result = await this.handleServerTiming('parse-resource', '', async () => parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, { withServerTiming, ...options }), withServerTiming)
503
514
  cid = result.cid
504
515
  path = result.path
505
516
  query = result.query
506
517
  ttl = result.ttl
507
518
  protocol = result.protocol
508
519
  ipfsPath = result.ipfsPath
520
+ this.serverTimingHeaders.push(...result.serverTimings.map(({ header }) => header))
509
521
  } catch (err: any) {
510
522
  options?.signal?.throwIfAborted()
511
523
  this.log.error('error parsing resource %s', resource, err)
512
524
 
513
- return badRequestResponse(resource.toString(), err)
525
+ return this.handleFinalResponse(badRequestResponse(resource.toString(), err))
514
526
  }
515
527
 
516
528
  options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:resolve', { cid, path }))
@@ -521,7 +533,7 @@ export class VerifiedFetch {
521
533
  this.log('output type %s', accept)
522
534
 
523
535
  if (acceptHeader != null && accept == null) {
524
- return notAcceptableResponse(resource.toString())
536
+ return this.handleFinalResponse(notAcceptableResponse(resource.toString()))
525
537
  }
526
538
 
527
539
  let response: Response
@@ -529,10 +541,10 @@ export class VerifiedFetch {
529
541
 
530
542
  const redirectResponse = await getRedirectResponse({ resource, options, logger: this.helia.logger, cid })
531
543
  if (redirectResponse != null) {
532
- return redirectResponse
544
+ return this.handleFinalResponse(redirectResponse)
533
545
  }
534
546
 
535
- const handlerArgs: FetchHandlerFunctionArg = { resource: resource.toString(), cid, path, accept, session: options?.session ?? true, options }
547
+ const handlerArgs: FetchHandlerFunctionArg = { resource: resource.toString(), cid, path, accept, session: options?.session ?? true, options, withServerTiming }
536
548
 
537
549
  if (accept === 'application/vnd.ipfs.ipns-record') {
538
550
  // the user requested a raw IPNS record
@@ -562,7 +574,7 @@ export class VerifiedFetch {
562
574
  const codecHandler = this.codecHandlers[cid.code]
563
575
 
564
576
  if (codecHandler == null) {
565
- return notSupportedResponse(`Support for codec with code ${cid.code} is not yet implemented. Please open an issue at https://github.com/ipfs/helia-verified-fetch/issues/new`)
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`))
566
578
  }
567
579
  this.log.trace('calling handler "%s"', codecHandler.name)
568
580
 
@@ -597,7 +609,7 @@ export class VerifiedFetch {
597
609
 
598
610
  options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid, path }))
599
611
 
600
- return response
612
+ return this.handleFinalResponse(response)
601
613
  }
602
614
 
603
615
  /**
@@ -614,7 +626,3 @@ export class VerifiedFetch {
614
626
  await this.helia.stop()
615
627
  }
616
628
  }
617
-
618
- function isPromise <T> (p?: any): p is Promise<T> {
619
- return p?.then != null
620
- }