@helia/verified-fetch 1.1.2 → 1.2.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 (45) hide show
  1. package/README.md +24 -1
  2. package/dist/index.min.js +8 -19
  3. package/dist/src/index.d.ts +29 -4
  4. package/dist/src/index.d.ts.map +1 -1
  5. package/dist/src/index.js +41 -5
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/types.d.ts +1 -0
  8. package/dist/src/types.d.ts.map +1 -1
  9. package/dist/src/utils/byte-range-context.d.ts +82 -0
  10. package/dist/src/utils/byte-range-context.d.ts.map +1 -0
  11. package/dist/src/utils/byte-range-context.js +275 -0
  12. package/dist/src/utils/byte-range-context.js.map +1 -0
  13. package/dist/src/utils/get-stream-from-async-iterable.js +1 -1
  14. package/dist/src/utils/parse-resource.d.ts +2 -2
  15. package/dist/src/utils/parse-url-string.d.ts +2 -2
  16. package/dist/src/utils/parse-url-string.d.ts.map +1 -1
  17. package/dist/src/utils/parse-url-string.js +5 -5
  18. package/dist/src/utils/parse-url-string.js.map +1 -1
  19. package/dist/src/utils/request-headers.d.ts +13 -0
  20. package/dist/src/utils/request-headers.d.ts.map +1 -0
  21. package/dist/src/utils/request-headers.js +50 -0
  22. package/dist/src/utils/request-headers.js.map +1 -0
  23. package/dist/src/utils/response-headers.d.ts +12 -0
  24. package/dist/src/utils/response-headers.d.ts.map +1 -0
  25. package/dist/src/utils/response-headers.js +29 -0
  26. package/dist/src/utils/response-headers.js.map +1 -0
  27. package/dist/src/utils/responses.d.ts +21 -4
  28. package/dist/src/utils/responses.d.ts.map +1 -1
  29. package/dist/src/utils/responses.js +58 -0
  30. package/dist/src/utils/responses.js.map +1 -1
  31. package/dist/src/verified-fetch.d.ts +2 -1
  32. package/dist/src/verified-fetch.d.ts.map +1 -1
  33. package/dist/src/verified-fetch.js +61 -25
  34. package/dist/src/verified-fetch.js.map +1 -1
  35. package/package.json +4 -3
  36. package/src/index.ts +49 -8
  37. package/src/types.ts +2 -0
  38. package/src/utils/byte-range-context.ts +303 -0
  39. package/src/utils/get-stream-from-async-iterable.ts +1 -1
  40. package/src/utils/parse-resource.ts +2 -2
  41. package/src/utils/parse-url-string.ts +7 -7
  42. package/src/utils/request-headers.ts +51 -0
  43. package/src/utils/response-headers.ts +32 -0
  44. package/src/utils/responses.ts +82 -4
  45. package/src/verified-fetch.ts +68 -29
@@ -1,3 +1,7 @@
1
+ import type { ByteRangeContext } from './byte-range-context'
2
+ import type { SupportedBodyTypes } from '../types.js'
3
+ import type { Logger } from '@libp2p/interface'
4
+
1
5
  function setField (response: Response, name: string, value: string | boolean): void {
2
6
  Object.defineProperty(response, name, {
3
7
  enumerable: true,
@@ -23,7 +27,7 @@ export interface ResponseOptions extends ResponseInit {
23
27
  redirected?: boolean
24
28
  }
25
29
 
26
- export function okResponse (url: string, body?: BodyInit | null, init?: ResponseOptions): Response {
30
+ export function okResponse (url: string, body?: SupportedBodyTypes, init?: ResponseOptions): Response {
27
31
  const response = new Response(body, {
28
32
  ...(init ?? {}),
29
33
  status: 200,
@@ -34,13 +38,27 @@ export function okResponse (url: string, body?: BodyInit | null, init?: Response
34
38
  setRedirected(response)
35
39
  }
36
40
 
41
+ setType(response, 'basic')
42
+ setUrl(response, url)
43
+ response.headers.set('Accept-Ranges', 'bytes')
44
+
45
+ return response
46
+ }
47
+
48
+ export function badGatewayResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response {
49
+ const response = new Response(body, {
50
+ ...(init ?? {}),
51
+ status: 502,
52
+ statusText: 'Bad Gateway'
53
+ })
54
+
37
55
  setType(response, 'basic')
38
56
  setUrl(response, url)
39
57
 
40
58
  return response
41
59
  }
42
60
 
43
- export function notSupportedResponse (url: string, body?: BodyInit | null, init?: ResponseInit): Response {
61
+ export function notSupportedResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response {
44
62
  const response = new Response(body, {
45
63
  ...(init ?? {}),
46
64
  status: 501,
@@ -54,7 +72,7 @@ export function notSupportedResponse (url: string, body?: BodyInit | null, init?
54
72
  return response
55
73
  }
56
74
 
57
- export function notAcceptableResponse (url: string, body?: BodyInit | null, init?: ResponseInit): Response {
75
+ export function notAcceptableResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response {
58
76
  const response = new Response(body, {
59
77
  ...(init ?? {}),
60
78
  status: 406,
@@ -67,7 +85,7 @@ export function notAcceptableResponse (url: string, body?: BodyInit | null, init
67
85
  return response
68
86
  }
69
87
 
70
- export function badRequestResponse (url: string, body?: BodyInit | null, init?: ResponseInit): Response {
88
+ export function badRequestResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response {
71
89
  const response = new Response(body, {
72
90
  ...(init ?? {}),
73
91
  status: 400,
@@ -96,3 +114,63 @@ export function movedPermanentlyResponse (url: string, location: string, init?:
96
114
 
97
115
  return response
98
116
  }
117
+
118
+ interface RangeOptions {
119
+ byteRangeContext: ByteRangeContext
120
+ log?: Logger
121
+ }
122
+
123
+ export function okRangeResponse (url: string, body: SupportedBodyTypes, { byteRangeContext, log }: RangeOptions, init?: ResponseOptions): Response {
124
+ if (!byteRangeContext.isRangeRequest) {
125
+ return okResponse(url, body, init)
126
+ }
127
+
128
+ if (!byteRangeContext.isValidRangeRequest) {
129
+ return badRangeResponse(url, body, init)
130
+ }
131
+
132
+ let response: Response
133
+ try {
134
+ response = new Response(body, {
135
+ ...(init ?? {}),
136
+ status: 206,
137
+ statusText: 'Partial Content',
138
+ headers: {
139
+ ...(init?.headers ?? {}),
140
+ 'content-range': byteRangeContext.contentRangeHeaderValue
141
+ }
142
+ })
143
+ } catch (e: any) {
144
+ log?.error('failed to create range response', e)
145
+ return badRangeResponse(url, body, init)
146
+ }
147
+
148
+ if (init?.redirected === true) {
149
+ setRedirected(response)
150
+ }
151
+
152
+ setType(response, 'basic')
153
+ setUrl(response, url)
154
+ response.headers.set('Accept-Ranges', 'bytes')
155
+
156
+ return response
157
+ }
158
+
159
+ /**
160
+ * We likely need to catch errors handled by upstream helia libraries if range-request throws an error. Some examples:
161
+ * * The range is out of bounds
162
+ * * The range is invalid
163
+ * * The range is not supported for the given type
164
+ */
165
+ export function badRangeResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response {
166
+ const response = new Response(body, {
167
+ ...(init ?? {}),
168
+ status: 416,
169
+ statusText: 'Requested Range Not Satisfiable'
170
+ })
171
+
172
+ setType(response, 'basic')
173
+ setUrl(response, url)
174
+
175
+ return response
176
+ }
@@ -1,7 +1,6 @@
1
1
  import { car } from '@helia/car'
2
- import { ipns as heliaIpns, type DNSResolver, type IPNS } from '@helia/ipns'
3
- import { dnsJsonOverHttps } from '@helia/ipns/dns-resolvers'
4
- import { unixfs as heliaUnixFs, type UnixFS as HeliaUnixFs, type UnixFSStats } from '@helia/unixfs'
2
+ import { ipns as heliaIpns, type IPNS } from '@helia/ipns'
3
+ import { unixfs as heliaUnixFs, type UnixFS as HeliaUnixFs } from '@helia/unixfs'
5
4
  import * as ipldDagCbor from '@ipld/dag-cbor'
6
5
  import * as ipldDagJson from '@ipld/dag-json'
7
6
  import { code as dagPbCode } from '@ipld/dag-pb'
@@ -16,19 +15,22 @@ import { CustomProgressEvent } from 'progress-events'
16
15
  import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
17
16
  import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
18
17
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
18
+ import { ByteRangeContext } from './utils/byte-range-context.js'
19
19
  import { dagCborToSafeJSON } from './utils/dag-cbor-to-safe-json.js'
20
20
  import { getContentDispositionFilename } from './utils/get-content-disposition-filename.js'
21
21
  import { getETag } from './utils/get-e-tag.js'
22
22
  import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterable.js'
23
23
  import { tarStream } from './utils/get-tar-stream.js'
24
24
  import { parseResource } from './utils/parse-resource.js'
25
- import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse } from './utils/responses.js'
25
+ import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse, badRangeResponse, okRangeResponse, badGatewayResponse } from './utils/responses.js'
26
26
  import { selectOutputType, queryFormatToAcceptHeader } from './utils/select-output-type.js'
27
27
  import { walkPath } from './utils/walk-path.js'
28
28
  import type { CIDDetail, ContentTypeParser, Resource, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
29
29
  import type { RequestFormatShorthand } from './types.js'
30
+ import type { ParsedUrlStringResults } from './utils/parse-url-string'
30
31
  import type { Helia } from '@helia/interface'
31
32
  import type { AbortOptions, Logger, PeerId } from '@libp2p/interface'
33
+ import type { DNSResolver } from '@multiformats/dns/resolvers'
32
34
  import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
33
35
  import type { CID } from 'multiformats/cid'
34
36
 
@@ -126,12 +128,7 @@ export class VerifiedFetch {
126
128
  constructor ({ helia, ipns, unixfs }: VerifiedFetchComponents, init?: VerifiedFetchInit) {
127
129
  this.helia = helia
128
130
  this.log = helia.logger.forComponent('helia:verified-fetch')
129
- this.ipns = ipns ?? heliaIpns(helia, {
130
- resolvers: init?.dnsResolvers ?? [
131
- dnsJsonOverHttps('https://mozilla.cloudflare-dns.com/dns-query'),
132
- dnsJsonOverHttps('https://dns.google/resolve')
133
- ]
134
- })
131
+ this.ipns = ipns ?? heliaIpns(helia)
135
132
  this.unixfs = unixfs ?? heliaUnixFs(helia)
136
133
  this.contentTypeParser = init?.contentTypeParser
137
134
  this.log.trace('created VerifiedFetch instance')
@@ -280,17 +277,19 @@ export class VerifiedFetch {
280
277
  let terminalElement: UnixFSEntry | undefined
281
278
  let ipfsRoots: CID[] | undefined
282
279
  let redirected = false
280
+ const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers)
283
281
 
284
282
  try {
285
283
  const pathDetails = await walkPath(this.helia.blockstore, `${cid.toString()}/${path}`, options)
286
284
  ipfsRoots = pathDetails.ipfsRoots
287
285
  terminalElement = pathDetails.terminalElement
288
286
  } catch (err) {
289
- this.log.error('Error walking path %s', path, err)
287
+ this.log.error('error walking path %s', path, err)
288
+
289
+ return badGatewayResponse('Error walking path')
290
290
  }
291
291
 
292
292
  let resolvedCID = terminalElement?.cid ?? cid
293
- let stat: UnixFSStats
294
293
  if (terminalElement?.type === 'directory') {
295
294
  const dirCid = terminalElement.cid
296
295
 
@@ -312,7 +311,7 @@ export class VerifiedFetch {
312
311
  const rootFilePath = 'index.html'
313
312
  try {
314
313
  this.log.trace('found directory at %c/%s, looking for index.html', cid, path)
315
- stat = await this.unixfs.stat(dirCid, {
314
+ const stat = await this.unixfs.stat(dirCid, {
316
315
  path: rootFilePath,
317
316
  signal: options?.signal,
318
317
  onProgress: options?.onProgress
@@ -328,30 +327,56 @@ export class VerifiedFetch {
328
327
  }
329
328
  }
330
329
 
330
+ // we have a validRangeRequest & terminalElement is a file, we know the size and should set it
331
+ if (byteRangeContext.isRangeRequest && byteRangeContext.isValidRangeRequest && terminalElement.type === 'file') {
332
+ byteRangeContext.setFileSize(terminalElement.unixfs.fileSize())
333
+
334
+ this.log.trace('fileSize for rangeRequest %d', byteRangeContext.getFileSize())
335
+ }
336
+ const offset = byteRangeContext.offset
337
+ const length = byteRangeContext.length
338
+ this.log.trace('calling unixfs.cat for %c/%s with offset=%o & length=%o', resolvedCID, path, offset, length)
331
339
  const asyncIter = this.unixfs.cat(resolvedCID, {
332
340
  signal: options?.signal,
333
- onProgress: options?.onProgress
341
+ onProgress: options?.onProgress,
342
+ offset,
343
+ length
334
344
  })
335
345
  this.log('got async iterator for %c/%s', cid, path)
336
346
 
337
- const { stream, firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
338
- onProgress: options?.onProgress
339
- })
340
- const response = okResponse(resource, stream, {
341
- redirected
342
- })
343
- await this.setContentType(firstChunk, path, response)
347
+ try {
348
+ const { stream, firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
349
+ onProgress: options?.onProgress
350
+ })
351
+ byteRangeContext.setBody(stream)
352
+ // if not a valid range request, okRangeRequest will call okResponse
353
+ const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log: this.log }, {
354
+ redirected
355
+ })
356
+
357
+ await this.setContentType(firstChunk, path, response)
358
+
359
+ if (ipfsRoots != null) {
360
+ 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
361
+ }
344
362
 
345
- if (ipfsRoots != null) {
346
- 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
363
+ return response
364
+ } catch (err: any) {
365
+ this.log.error('error streaming %c/%s', cid, path, err)
366
+ if (byteRangeContext.isRangeRequest && err.code === 'ERR_INVALID_PARAMS') {
367
+ return badRangeResponse(resource)
368
+ }
369
+ return badGatewayResponse('Unable to stream content')
347
370
  }
348
-
349
- return response
350
371
  }
351
372
 
352
373
  private async handleRaw ({ resource, cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
374
+ const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers)
353
375
  const result = await this.helia.blockstore.get(cid, options)
354
- const response = okResponse(resource, result)
376
+ byteRangeContext.setBody(result)
377
+ const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log: this.log }, {
378
+ redirected: false
379
+ })
355
380
 
356
381
  // if the user has specified an `Accept` header that corresponds to a raw
357
382
  // type, honour that header, so for example they don't request
@@ -385,10 +410,10 @@ export class VerifiedFetch {
385
410
  contentType = parsed
386
411
  }
387
412
  } catch (err) {
388
- this.log.error('Error parsing content type', err)
413
+ this.log.error('error parsing content type', err)
389
414
  }
390
415
  }
391
-
416
+ this.log.trace('setting content type to "%s"', contentType)
392
417
  response.headers.set('content-type', contentType)
393
418
  }
394
419
 
@@ -413,7 +438,19 @@ export class VerifiedFetch {
413
438
  options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { resource }))
414
439
 
415
440
  // resolve the CID/path from the requested resource
416
- const { path, query, cid } = await parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, options)
441
+ let cid: ParsedUrlStringResults['cid']
442
+ let path: ParsedUrlStringResults['path']
443
+ let query: ParsedUrlStringResults['query']
444
+ try {
445
+ const result = await parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, options)
446
+ cid = result.cid
447
+ path = result.path
448
+ query = result.query
449
+ } catch (err) {
450
+ this.log.error('error parsing resource %s', resource, err)
451
+
452
+ return badRequestResponse('Invalid resource')
453
+ }
417
454
 
418
455
  options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:resolve', { cid, path }))
419
456
 
@@ -466,12 +503,14 @@ export class VerifiedFetch {
466
503
  query.filename = query.filename ?? `${cid.toString()}.tar`
467
504
  response = await this.handleTar(handlerArgs)
468
505
  } else {
506
+ this.log.trace('finding handler for cid code "%s" and output type "%s"', cid.code, accept)
469
507
  // derive the handler from the CID type
470
508
  const codecHandler = this.codecHandlers[cid.code]
471
509
 
472
510
  if (codecHandler == null) {
473
511
  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`)
474
512
  }
513
+ this.log.trace('calling handler "%s"', codecHandler.name)
475
514
 
476
515
  response = await codecHandler.call(this, handlerArgs)
477
516
  }