@helia/verified-fetch 1.1.3 → 1.2.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 (33) hide show
  1. package/dist/index.min.js +7 -7
  2. package/dist/src/types.d.ts +1 -0
  3. package/dist/src/types.d.ts.map +1 -1
  4. package/dist/src/utils/byte-range-context.d.ts +80 -0
  5. package/dist/src/utils/byte-range-context.d.ts.map +1 -0
  6. package/dist/src/utils/byte-range-context.js +277 -0
  7. package/dist/src/utils/byte-range-context.js.map +1 -0
  8. package/dist/src/utils/get-stream-from-async-iterable.js +1 -1
  9. package/dist/src/utils/parse-url-string.js +4 -4
  10. package/dist/src/utils/request-headers.d.ts +13 -0
  11. package/dist/src/utils/request-headers.d.ts.map +1 -0
  12. package/dist/src/utils/request-headers.js +62 -0
  13. package/dist/src/utils/request-headers.js.map +1 -0
  14. package/dist/src/utils/response-headers.d.ts +12 -0
  15. package/dist/src/utils/response-headers.d.ts.map +1 -0
  16. package/dist/src/utils/response-headers.js +37 -0
  17. package/dist/src/utils/response-headers.js.map +1 -0
  18. package/dist/src/utils/responses.d.ts +21 -4
  19. package/dist/src/utils/responses.d.ts.map +1 -1
  20. package/dist/src/utils/responses.js +58 -0
  21. package/dist/src/utils/responses.js.map +1 -1
  22. package/dist/src/verified-fetch.d.ts.map +1 -1
  23. package/dist/src/verified-fetch.js +60 -18
  24. package/dist/src/verified-fetch.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/types.ts +2 -0
  27. package/src/utils/byte-range-context.ts +301 -0
  28. package/src/utils/get-stream-from-async-iterable.ts +1 -1
  29. package/src/utils/parse-url-string.ts +4 -4
  30. package/src/utils/request-headers.ts +63 -0
  31. package/src/utils/response-headers.ts +42 -0
  32. package/src/utils/responses.ts +82 -4
  33. package/src/verified-fetch.ts +65 -21
@@ -106,7 +106,7 @@ export async function parseUrlString ({ urlString, ipns, logger }: ParseUrlStrin
106
106
  log.trace('resolved %s to %c from cache', cidOrPeerIdOrDnsLink, cid)
107
107
  } else {
108
108
  // protocol is ipns
109
- log.trace('Attempting to resolve PeerId for %s', cidOrPeerIdOrDnsLink)
109
+ log.trace('attempting to resolve PeerId for %s', cidOrPeerIdOrDnsLink)
110
110
  let peerId = null
111
111
  try {
112
112
  peerId = peerIdFromString(cidOrPeerIdOrDnsLink)
@@ -117,10 +117,10 @@ export async function parseUrlString ({ urlString, ipns, logger }: ParseUrlStrin
117
117
  ipnsCache.set(cidOrPeerIdOrDnsLink, resolveResult, 60 * 1000 * 2)
118
118
  } catch (err) {
119
119
  if (peerId == null) {
120
- log.error('Could not parse PeerId string "%s"', cidOrPeerIdOrDnsLink, err)
120
+ log.error('could not parse PeerId string "%s"', cidOrPeerIdOrDnsLink, err)
121
121
  errors.push(new TypeError(`Could not parse PeerId in ipns url "${cidOrPeerIdOrDnsLink}", ${(err as Error).message}`))
122
122
  } else {
123
- log.error('Could not resolve PeerId %c', peerId, err)
123
+ log.error('could not resolve PeerId %c', peerId, err)
124
124
  errors.push(new TypeError(`Could not resolve PeerId "${cidOrPeerIdOrDnsLink}", ${(err as Error).message}`))
125
125
  }
126
126
  }
@@ -140,7 +140,7 @@ export async function parseUrlString ({ urlString, ipns, logger }: ParseUrlStrin
140
140
  log.trace('resolved %s to %c', decodedDnsLinkLabel, cid)
141
141
  ipnsCache.set(cidOrPeerIdOrDnsLink, resolveResult, 60 * 1000 * 2)
142
142
  } catch (err: any) {
143
- log.error('Could not resolve DnsLink for "%s"', cidOrPeerIdOrDnsLink, err)
143
+ log.error('could not resolve DnsLink for "%s"', cidOrPeerIdOrDnsLink, err)
144
144
  errors.push(err)
145
145
  }
146
146
  }
@@ -0,0 +1,63 @@
1
+ export function getHeader (headers: HeadersInit | undefined, header: string): string | undefined {
2
+ if (headers == null) {
3
+ return undefined
4
+ }
5
+ if (headers instanceof Headers) {
6
+ return headers.get(header) ?? undefined
7
+ }
8
+ if (Array.isArray(headers)) {
9
+ const entry = headers.find(([key]) => key.toLowerCase() === header.toLowerCase())
10
+ return entry?.[1]
11
+ }
12
+ const key = Object.keys(headers).find(k => k.toLowerCase() === header.toLowerCase())
13
+ if (key == null) {
14
+ return undefined
15
+ }
16
+
17
+ return headers[key]
18
+ }
19
+
20
+ /**
21
+ * Given two ints from a Range header, and potential fileSize, returns:
22
+ * 1. number of bytes the response should contain.
23
+ * 2. the start index of the range. // inclusive
24
+ * 3. the end index of the range. // inclusive
25
+ */
26
+ // eslint-disable-next-line complexity
27
+ export function calculateByteRangeIndexes (start: number | undefined, end: number | undefined, fileSize?: number): { byteSize?: number, start?: number, end?: number } {
28
+ if ((start ?? 0) > (end ?? Infinity)) {
29
+ throw new Error('Invalid range: Range-start index is greater than range-end index.')
30
+ }
31
+ if (start != null && (end ?? 0) >= (fileSize ?? Infinity)) {
32
+ throw new Error('Invalid range: Range-end index is greater than or equal to the size of the file.')
33
+ }
34
+ if (start == null && (end ?? 0) > (fileSize ?? Infinity)) {
35
+ throw new Error('Invalid range: Range-end index is greater than the size of the file.')
36
+ }
37
+ if (start != null && start < 0) {
38
+ throw new Error('Invalid range: Range-start index cannot be negative.')
39
+ }
40
+
41
+ if (start != null && end != null) {
42
+ return { byteSize: end - start + 1, start, end }
43
+ } else if (start == null && end != null) {
44
+ // suffix byte range requested
45
+ if (fileSize == null) {
46
+ return { end }
47
+ }
48
+ if (end === fileSize) {
49
+ return { byteSize: fileSize, start: 0, end: fileSize - 1 }
50
+ }
51
+ return { byteSize: end, start: fileSize - end, end: fileSize - 1 }
52
+ } else if (start != null && end == null) {
53
+ if (fileSize == null) {
54
+ // we only have the start index, and no fileSize, so we can't return a valid range.
55
+ return { start }
56
+ }
57
+ const end = fileSize - 1
58
+ const byteSize = fileSize - start
59
+ return { byteSize, start, end }
60
+ }
61
+
62
+ return { byteSize: fileSize, start: 0, end: fileSize != null ? fileSize - 1 : 0 }
63
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * This function returns the value of the `Content-Range` header for a given range.
3
+ * If you know the total size of the body, pass it as `byteSize`
4
+ *
5
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range
6
+ */
7
+ export function getContentRangeHeader ({ byteStart, byteEnd, byteSize }: { byteStart: number | undefined, byteEnd: number | undefined, byteSize: number | undefined }): string {
8
+ const total = byteSize ?? '*' // if we don't know the total size, we should use *
9
+
10
+ if ((byteEnd ?? 0) >= (byteSize ?? Infinity)) {
11
+ throw new Error('Invalid range: Range-end index is greater than or equal to the size of the file.')
12
+ }
13
+ if ((byteStart ?? 0) >= (byteSize ?? Infinity)) {
14
+ throw new Error('Invalid range: Range-start index is greater than or equal to the size of the file.')
15
+ }
16
+
17
+ if (byteStart != null && byteEnd == null) {
18
+ // only byteStart in range
19
+ if (byteSize == null) {
20
+ return `bytes */${total}`
21
+ }
22
+ return `bytes ${byteStart}-${byteSize - 1}/${byteSize}`
23
+ }
24
+
25
+ if (byteStart == null && byteEnd != null) {
26
+ // only byteEnd in range
27
+ if (byteSize == null) {
28
+ return `bytes */${total}`
29
+ }
30
+ const end = byteSize - 1
31
+ const start = end - byteEnd + 1
32
+
33
+ return `bytes ${start}-${end}/${byteSize}`
34
+ }
35
+
36
+ if (byteStart == null && byteEnd == null) {
37
+ // neither are provided, we can't return a valid range.
38
+ return `bytes */${total}`
39
+ }
40
+
41
+ return `bytes ${byteStart}-${byteEnd}/${total}`
42
+ }
@@ -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,6 +1,6 @@
1
1
  import { car } from '@helia/car'
2
2
  import { ipns as heliaIpns, type IPNS } from '@helia/ipns'
3
- import { unixfs as heliaUnixFs, type UnixFS as HeliaUnixFs, type UnixFSStats } from '@helia/unixfs'
3
+ 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'
@@ -15,17 +15,19 @@ import { CustomProgressEvent } from 'progress-events'
15
15
  import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
16
16
  import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
17
17
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
18
+ import { ByteRangeContext } from './utils/byte-range-context.js'
18
19
  import { dagCborToSafeJSON } from './utils/dag-cbor-to-safe-json.js'
19
20
  import { getContentDispositionFilename } from './utils/get-content-disposition-filename.js'
20
21
  import { getETag } from './utils/get-e-tag.js'
21
22
  import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterable.js'
22
23
  import { tarStream } from './utils/get-tar-stream.js'
23
24
  import { parseResource } from './utils/parse-resource.js'
24
- import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse } from './utils/responses.js'
25
+ import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse, badRangeResponse, okRangeResponse, badGatewayResponse } from './utils/responses.js'
25
26
  import { selectOutputType, queryFormatToAcceptHeader } from './utils/select-output-type.js'
26
27
  import { walkPath } from './utils/walk-path.js'
27
28
  import type { CIDDetail, ContentTypeParser, Resource, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
28
29
  import type { RequestFormatShorthand } from './types.js'
30
+ import type { ParsedUrlStringResults } from './utils/parse-url-string'
29
31
  import type { Helia } from '@helia/interface'
30
32
  import type { AbortOptions, Logger, PeerId } from '@libp2p/interface'
31
33
  import type { DNSResolver } from '@multiformats/dns/resolvers'
@@ -275,17 +277,19 @@ export class VerifiedFetch {
275
277
  let terminalElement: UnixFSEntry | undefined
276
278
  let ipfsRoots: CID[] | undefined
277
279
  let redirected = false
280
+ const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers)
278
281
 
279
282
  try {
280
283
  const pathDetails = await walkPath(this.helia.blockstore, `${cid.toString()}/${path}`, options)
281
284
  ipfsRoots = pathDetails.ipfsRoots
282
285
  terminalElement = pathDetails.terminalElement
283
286
  } catch (err) {
284
- 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')
285
290
  }
286
291
 
287
292
  let resolvedCID = terminalElement?.cid ?? cid
288
- let stat: UnixFSStats
289
293
  if (terminalElement?.type === 'directory') {
290
294
  const dirCid = terminalElement.cid
291
295
 
@@ -307,7 +311,7 @@ export class VerifiedFetch {
307
311
  const rootFilePath = 'index.html'
308
312
  try {
309
313
  this.log.trace('found directory at %c/%s, looking for index.html', cid, path)
310
- stat = await this.unixfs.stat(dirCid, {
314
+ const stat = await this.unixfs.stat(dirCid, {
311
315
  path: rootFilePath,
312
316
  signal: options?.signal,
313
317
  onProgress: options?.onProgress
@@ -323,30 +327,56 @@ export class VerifiedFetch {
323
327
  }
324
328
  }
325
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)
326
339
  const asyncIter = this.unixfs.cat(resolvedCID, {
327
340
  signal: options?.signal,
328
- onProgress: options?.onProgress
341
+ onProgress: options?.onProgress,
342
+ offset,
343
+ length
329
344
  })
330
345
  this.log('got async iterator for %c/%s', cid, path)
331
346
 
332
- const { stream, firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
333
- onProgress: options?.onProgress
334
- })
335
- const response = okResponse(resource, stream, {
336
- redirected
337
- })
338
- 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
+ }
339
362
 
340
- if (ipfsRoots != null) {
341
- 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')
342
370
  }
343
-
344
- return response
345
371
  }
346
372
 
347
373
  private async handleRaw ({ resource, cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
374
+ const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers)
348
375
  const result = await this.helia.blockstore.get(cid, options)
349
- const response = okResponse(resource, result)
376
+ byteRangeContext.setBody(result)
377
+ const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log: this.log }, {
378
+ redirected: false
379
+ })
350
380
 
351
381
  // if the user has specified an `Accept` header that corresponds to a raw
352
382
  // type, honour that header, so for example they don't request
@@ -380,10 +410,10 @@ export class VerifiedFetch {
380
410
  contentType = parsed
381
411
  }
382
412
  } catch (err) {
383
- this.log.error('Error parsing content type', err)
413
+ this.log.error('error parsing content type', err)
384
414
  }
385
415
  }
386
-
416
+ this.log.trace('setting content type to "%s"', contentType)
387
417
  response.headers.set('content-type', contentType)
388
418
  }
389
419
 
@@ -408,7 +438,19 @@ export class VerifiedFetch {
408
438
  options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { resource }))
409
439
 
410
440
  // resolve the CID/path from the requested resource
411
- 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
+ }
412
454
 
413
455
  options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:resolve', { cid, path }))
414
456
 
@@ -461,12 +503,14 @@ export class VerifiedFetch {
461
503
  query.filename = query.filename ?? `${cid.toString()}.tar`
462
504
  response = await this.handleTar(handlerArgs)
463
505
  } else {
506
+ this.log.trace('finding handler for cid code "%s" and output type "%s"', cid.code, accept)
464
507
  // derive the handler from the CID type
465
508
  const codecHandler = this.codecHandlers[cid.code]
466
509
 
467
510
  if (codecHandler == null) {
468
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`)
469
512
  }
513
+ this.log.trace('calling handler "%s"', codecHandler.name)
470
514
 
471
515
  response = await codecHandler.call(this, handlerArgs)
472
516
  }