@helia/verified-fetch 2.3.1 → 2.5.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 (119) hide show
  1. package/README.md +200 -0
  2. package/dist/index.min.js +357 -35
  3. package/dist/src/index.d.ts +220 -0
  4. package/dist/src/index.d.ts.map +1 -1
  5. package/dist/src/index.js +200 -0
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/plugins/errors.d.ts +25 -0
  8. package/dist/src/plugins/errors.d.ts.map +1 -0
  9. package/dist/src/plugins/errors.js +33 -0
  10. package/dist/src/plugins/errors.js.map +1 -0
  11. package/dist/src/plugins/index.d.ts +8 -0
  12. package/dist/src/plugins/index.d.ts.map +1 -0
  13. package/dist/src/plugins/index.js +7 -0
  14. package/dist/src/plugins/index.js.map +1 -0
  15. package/dist/src/plugins/plugin-base.d.ts +19 -0
  16. package/dist/src/plugins/plugin-base.d.ts.map +1 -0
  17. package/dist/src/plugins/plugin-base.js +26 -0
  18. package/dist/src/plugins/plugin-base.js.map +1 -0
  19. package/dist/src/plugins/plugin-handle-car.d.ts +11 -0
  20. package/dist/src/plugins/plugin-handle-car.d.ts.map +1 -0
  21. package/dist/src/plugins/plugin-handle-car.js +28 -0
  22. package/dist/src/plugins/plugin-handle-car.js.map +1 -0
  23. package/dist/src/plugins/plugin-handle-dag-cbor.d.ts +11 -0
  24. package/dist/src/plugins/plugin-handle-dag-cbor.d.ts.map +1 -0
  25. package/dist/src/plugins/plugin-handle-dag-cbor.js +73 -0
  26. package/dist/src/plugins/plugin-handle-dag-cbor.js.map +1 -0
  27. package/dist/src/plugins/plugin-handle-dag-pb.d.ts +15 -0
  28. package/dist/src/plugins/plugin-handle-dag-pb.d.ts.map +1 -0
  29. package/dist/src/plugins/plugin-handle-dag-pb.js +152 -0
  30. package/dist/src/plugins/plugin-handle-dag-pb.js.map +1 -0
  31. package/dist/src/plugins/plugin-handle-dag-walk.d.ts +16 -0
  32. package/dist/src/plugins/plugin-handle-dag-walk.d.ts.map +1 -0
  33. package/dist/src/plugins/plugin-handle-dag-walk.js +45 -0
  34. package/dist/src/plugins/plugin-handle-dag-walk.js.map +1 -0
  35. package/dist/src/plugins/plugin-handle-dir-index-html.d.ts +9 -0
  36. package/dist/src/plugins/plugin-handle-dir-index-html.d.ts.map +1 -0
  37. package/dist/src/plugins/plugin-handle-dir-index-html.js +37 -0
  38. package/dist/src/plugins/plugin-handle-dir-index-html.js.map +1 -0
  39. package/dist/src/plugins/plugin-handle-ipns-record.d.ts +12 -0
  40. package/dist/src/plugins/plugin-handle-ipns-record.d.ts.map +1 -0
  41. package/dist/src/plugins/plugin-handle-ipns-record.js +62 -0
  42. package/dist/src/plugins/plugin-handle-ipns-record.js.map +1 -0
  43. package/dist/src/plugins/plugin-handle-json.d.ts +11 -0
  44. package/dist/src/plugins/plugin-handle-json.d.ts.map +1 -0
  45. package/dist/src/plugins/plugin-handle-json.js +51 -0
  46. package/dist/src/plugins/plugin-handle-json.js.map +1 -0
  47. package/dist/src/plugins/plugin-handle-raw.d.ts +8 -0
  48. package/dist/src/plugins/plugin-handle-raw.d.ts.map +1 -0
  49. package/dist/src/plugins/plugin-handle-raw.js +80 -0
  50. package/dist/src/plugins/plugin-handle-raw.js.map +1 -0
  51. package/dist/src/plugins/plugin-handle-tar.d.ts +12 -0
  52. package/dist/src/plugins/plugin-handle-tar.d.ts.map +1 -0
  53. package/dist/src/plugins/plugin-handle-tar.js +36 -0
  54. package/dist/src/plugins/plugin-handle-tar.js.map +1 -0
  55. package/dist/src/plugins/plugins.d.ts +5 -0
  56. package/dist/src/plugins/plugins.d.ts.map +1 -0
  57. package/dist/src/plugins/plugins.js +5 -0
  58. package/dist/src/plugins/plugins.js.map +1 -0
  59. package/dist/src/plugins/types.d.ts +68 -0
  60. package/dist/src/plugins/types.d.ts.map +1 -0
  61. package/dist/src/plugins/types.js +2 -0
  62. package/dist/src/plugins/types.js.map +1 -0
  63. package/dist/src/types.d.ts +0 -23
  64. package/dist/src/types.d.ts.map +1 -1
  65. package/dist/src/types.js +1 -2
  66. package/dist/src/types.js.map +1 -1
  67. package/dist/src/utils/dir-index-html.d.ts +16 -0
  68. package/dist/src/utils/dir-index-html.d.ts.map +1 -0
  69. package/dist/src/utils/dir-index-html.js +387 -0
  70. package/dist/src/utils/dir-index-html.js.map +1 -0
  71. package/dist/src/utils/get-e-tag.d.ts +1 -1
  72. package/dist/src/utils/get-e-tag.d.ts.map +1 -1
  73. package/dist/src/utils/get-e-tag.js +18 -3
  74. package/dist/src/utils/get-e-tag.js.map +1 -1
  75. package/dist/src/utils/parse-resource.d.ts +2 -1
  76. package/dist/src/utils/parse-resource.d.ts.map +1 -1
  77. package/dist/src/utils/parse-resource.js +4 -3
  78. package/dist/src/utils/parse-resource.js.map +1 -1
  79. package/dist/src/utils/parse-url-string.d.ts +8 -3
  80. package/dist/src/utils/parse-url-string.d.ts.map +1 -1
  81. package/dist/src/utils/parse-url-string.js +30 -4
  82. package/dist/src/utils/parse-url-string.js.map +1 -1
  83. package/dist/src/utils/server-timing.d.ts +13 -0
  84. package/dist/src/utils/server-timing.d.ts.map +1 -0
  85. package/dist/src/utils/server-timing.js +19 -0
  86. package/dist/src/utils/server-timing.js.map +1 -0
  87. package/dist/src/utils/walk-path.d.ts +3 -2
  88. package/dist/src/utils/walk-path.d.ts.map +1 -1
  89. package/dist/src/utils/walk-path.js +1 -1
  90. package/dist/src/utils/walk-path.js.map +1 -1
  91. package/dist/src/verified-fetch.d.ts +11 -20
  92. package/dist/src/verified-fetch.d.ts.map +1 -1
  93. package/dist/src/verified-fetch.js +174 -367
  94. package/dist/src/verified-fetch.js.map +1 -1
  95. package/dist/typedoc-urls.json +32 -24
  96. package/package.json +6 -2
  97. package/src/index.ts +223 -0
  98. package/src/plugins/errors.ts +37 -0
  99. package/src/plugins/index.ts +8 -0
  100. package/src/plugins/plugin-base.ts +30 -0
  101. package/src/plugins/plugin-handle-car.ts +32 -0
  102. package/src/plugins/plugin-handle-dag-cbor.ts +84 -0
  103. package/src/plugins/plugin-handle-dag-pb.ts +168 -0
  104. package/src/plugins/plugin-handle-dag-walk.ts +53 -0
  105. package/src/plugins/plugin-handle-dir-index-html.ts +44 -0
  106. package/src/plugins/plugin-handle-ipns-record.ts +69 -0
  107. package/src/plugins/plugin-handle-json.ts +57 -0
  108. package/src/plugins/plugin-handle-raw.ts +92 -0
  109. package/src/plugins/plugin-handle-tar.ts +44 -0
  110. package/src/plugins/plugins.ts +4 -0
  111. package/src/plugins/types.ts +73 -0
  112. package/src/types.ts +0 -29
  113. package/src/utils/dir-index-html.ts +445 -0
  114. package/src/utils/get-e-tag.ts +20 -3
  115. package/src/utils/parse-resource.ts +5 -4
  116. package/src/utils/parse-url-string.ts +38 -7
  117. package/src/utils/server-timing.ts +37 -0
  118. package/src/utils/walk-path.ts +3 -3
  119. package/src/verified-fetch.ts +198 -403
@@ -0,0 +1,168 @@
1
+ import { unixfs } from '@helia/unixfs'
2
+ import { code as dagPbCode } from '@ipld/dag-pb'
3
+ import { exporter } from 'ipfs-unixfs-exporter'
4
+ import { CustomProgressEvent } from 'progress-events'
5
+ import { ByteRangeContext } from '../utils/byte-range-context.js'
6
+ import { getStreamFromAsyncIterable } from '../utils/get-stream-from-async-iterable.js'
7
+ import { setIpfsRoots } from '../utils/response-headers.js'
8
+ import { badGatewayResponse, badRangeResponse, movedPermanentlyResponse, notSupportedResponse, okRangeResponse } from '../utils/responses.js'
9
+ import { setContentType } from '../utils/set-content-type.js'
10
+ import { BasePlugin } from './plugin-base.js'
11
+ import type { PluginContext } from './types.js'
12
+ import type { CIDDetail } from '../index.js'
13
+
14
+ /**
15
+ * Handles UnixFS and dag-pb content.
16
+ */
17
+ export class DagPbPlugin extends BasePlugin {
18
+ readonly codes = [dagPbCode]
19
+ canHandle ({ cid, accept, pathDetails }: PluginContext): boolean {
20
+ this.log('checking if we can handle %c with accept %s', cid, accept)
21
+ if (pathDetails == null) {
22
+ return false
23
+ }
24
+
25
+ return cid.code === dagPbCode
26
+ }
27
+
28
+ /**
29
+ * @see https://specs.ipfs.tech/http-gateways/path-gateway/#use-in-directory-url-normalization
30
+ */
31
+ getRedirectUrl (context: PluginContext): string | null {
32
+ const { resource, path } = context
33
+ const redirectCheckNeeded = path === '' ? !resource.toString().endsWith('/') : !path.endsWith('/')
34
+ if (redirectCheckNeeded) {
35
+ try {
36
+ const url = new URL(resource.toString())
37
+ // make sure we append slash to end of the path
38
+ url.pathname = `${url.pathname}/`
39
+ return url.toString()
40
+ } catch (err: any) {
41
+ // resource is likely a CID
42
+ return `${resource.toString()}/`
43
+ }
44
+ }
45
+ return null
46
+ }
47
+
48
+ async handle (context: PluginContext): Promise<Response | null> {
49
+ const { cid, options, withServerTiming = false, pathDetails } = context
50
+ const { handleServerTiming, contentTypeParser, helia, getBlockstore } = this.pluginOptions
51
+ const log = this.log
52
+ let resource = context.resource
53
+ let path = context.path
54
+
55
+ let redirected = false
56
+ const byteRangeContext = new ByteRangeContext(this.pluginOptions.logger, options?.headers)
57
+
58
+ if (pathDetails == null) {
59
+ throw new TypeError('Path details are required')
60
+ }
61
+ const ipfsRoots = pathDetails.ipfsRoots
62
+ const terminalElement = pathDetails.terminalElement
63
+ let resolvedCID = terminalElement.cid
64
+
65
+ if (terminalElement?.type === 'directory') {
66
+ const dirCid = terminalElement.cid
67
+ const redirectUrl = this.getRedirectUrl(context)
68
+
69
+ if (redirectUrl != null) {
70
+ log.trace('directory url normalization spec requires redirect...')
71
+ if (options?.redirect === 'error') {
72
+ log('could not redirect to %s as redirect option was set to "error"', redirectUrl)
73
+ throw new TypeError('Failed to fetch')
74
+ } else if (options?.redirect === 'manual') {
75
+ log('returning 301 permanent redirect to %s', redirectUrl)
76
+ return movedPermanentlyResponse(resource, redirectUrl)
77
+ }
78
+ log('following redirect to %s', redirectUrl)
79
+
80
+ // fall-through simulates following the redirect?
81
+ resource = redirectUrl
82
+ redirected = true
83
+ }
84
+
85
+ const rootFilePath = 'index.html'
86
+ try {
87
+ log.trace('found directory at %c/%s, looking for index.html', cid, path)
88
+
89
+ const entry = await handleServerTiming('exporter-dir', '', async () => exporter(`/ipfs/${dirCid}/${rootFilePath}`, helia.blockstore, {
90
+ signal: options?.signal,
91
+ onProgress: options?.onProgress
92
+ }), withServerTiming)
93
+
94
+ log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, entry.cid)
95
+ path = rootFilePath
96
+ resolvedCID = entry.cid
97
+ } catch (err: any) {
98
+ this.log.error('error loading path %c/%s', dirCid, rootFilePath, err)
99
+ options?.signal?.throwIfAborted()
100
+ context.isDirectory = true
101
+ context.directoryEntries = []
102
+ this.log.trace('attempting to get directory entries because index.html was not found')
103
+ const fs = unixfs({ ...helia, blockstore: getBlockstore(context.cid, context.resource, options?.session ?? true, options) })
104
+ try {
105
+ for await (const dirItem of fs.ls(dirCid, { signal: options?.signal, onProgress: options?.onProgress })) {
106
+ context.directoryEntries.push(dirItem)
107
+ }
108
+ // dir-index-html plugin or dir-index-json (future idea?) plugin should handle this
109
+ return null
110
+ } catch (e) {
111
+ log.error('error listing directory %c', dirCid, e)
112
+ return notSupportedResponse('Unable to get directory contents')
113
+ }
114
+ } finally {
115
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: dirCid, path: rootFilePath }))
116
+ }
117
+ }
118
+
119
+ // we have a validRangeRequest & terminalElement is a file, we know the size and should set it
120
+ if (byteRangeContext.isRangeRequest && byteRangeContext.isValidRangeRequest && terminalElement.type === 'file') {
121
+ byteRangeContext.setFileSize(terminalElement.unixfs.fileSize())
122
+
123
+ log.trace('fileSize for rangeRequest %d', byteRangeContext.getFileSize())
124
+ }
125
+ const offset = byteRangeContext.offset
126
+ const length = byteRangeContext.length
127
+ log.trace('calling exporter for %c/%s with offset=%o & length=%o', resolvedCID, path, offset, length)
128
+
129
+ try {
130
+ const entry = await handleServerTiming('exporter-file', '', async () => exporter(resolvedCID, helia.blockstore, {
131
+ signal: options?.signal,
132
+ onProgress: options?.onProgress
133
+ }), withServerTiming)
134
+
135
+ const asyncIter = entry.content({
136
+ signal: options?.signal,
137
+ onProgress: options?.onProgress,
138
+ offset,
139
+ length
140
+ })
141
+ log('got async iterator for %c/%s', cid, path)
142
+
143
+ const { stream, firstChunk } = await handleServerTiming('stream-and-chunk', '', async () => getStreamFromAsyncIterable(asyncIter, path ?? '', this.pluginOptions.logger, {
144
+ onProgress: options?.onProgress,
145
+ signal: options?.signal
146
+ }), withServerTiming)
147
+
148
+ byteRangeContext.setBody(stream)
149
+ // if not a valid range request, okRangeRequest will call okResponse
150
+ const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log }, {
151
+ redirected
152
+ })
153
+
154
+ await handleServerTiming('set-content-type', '', async () => setContentType({ bytes: firstChunk, path, response, contentTypeParser, log }), withServerTiming)
155
+
156
+ setIpfsRoots(response, ipfsRoots)
157
+
158
+ return response
159
+ } catch (err: any) {
160
+ options?.signal?.throwIfAborted()
161
+ log.error('error streaming %c/%s', cid, path, err)
162
+ if (byteRangeContext.isRangeRequest && err.code === 'ERR_INVALID_PARAMS') {
163
+ return badRangeResponse(resource)
164
+ }
165
+ return badGatewayResponse(resource.toString(), 'Unable to stream content')
166
+ }
167
+ }
168
+ }
@@ -0,0 +1,53 @@
1
+ import { code as dagCborCode } from '@ipld/dag-cbor'
2
+ import { code as dagPbCode } from '@ipld/dag-pb'
3
+ import { handlePathWalking } from '../utils/walk-path.js'
4
+ import { BasePlugin } from './plugin-base.js'
5
+ import type { PluginContext } from './types.js'
6
+
7
+ /**
8
+ * This plugin should almost always run first because it's going to handle path walking if needed, and will only say it can handle
9
+ * the request if path walking is possible (path is not empty, terminalCid is unknown, and the path has not been walked yet).
10
+ *
11
+ * Once this plugin has run, the PluginContext will be updated and then this plugin will return false for canHandle, so it won't run again.
12
+ */
13
+ export class DagWalkPlugin extends BasePlugin {
14
+ /**
15
+ * Return false if the path has already been walked, otherwise return true if the CID is encoded with a codec that supports pathing.
16
+ */
17
+ canHandle (context: PluginContext): boolean {
18
+ this.log('checking if we can handle %c with accept %s', context.cid, context.accept)
19
+ const { pathDetails, cid } = context
20
+ if (pathDetails != null) {
21
+ // path has already been walked
22
+ return false
23
+ }
24
+
25
+ return (cid.code === dagPbCode || cid.code === dagCborCode)
26
+ }
27
+
28
+ async handle (context: PluginContext): Promise<Response | null> {
29
+ const { cid, resource, options, withServerTiming = false } = context
30
+ const { getBlockstore, handleServerTiming } = this.pluginOptions
31
+ const blockstore = getBlockstore(cid, resource, options?.session ?? true, options)
32
+ // TODO: migrate handlePathWalking into this plugin
33
+ const pathDetails = await handleServerTiming('path-walking', '', async () => handlePathWalking({ ...context, blockstore, log: this.log }), withServerTiming)
34
+
35
+ context.modified++
36
+ if (pathDetails instanceof Response) {
37
+ this.log.trace('path walking failed')
38
+
39
+ if (pathDetails.status === 404) {
40
+ // invalid or incorrect path.. we walked the path but nothing is there
41
+ // send the 404 response
42
+ return pathDetails
43
+ }
44
+
45
+ // some error walking the path
46
+ return null
47
+ }
48
+
49
+ context.pathDetails = pathDetails
50
+
51
+ return null
52
+ }
53
+ }
@@ -0,0 +1,44 @@
1
+ import { code as dagPbCode } from '@ipld/dag-pb'
2
+ import { dirIndexHtml } from '../utils/dir-index-html.js'
3
+ import { okResponse } from '../utils/responses.js'
4
+ import { BasePlugin } from './plugin-base.js'
5
+ import type { PluginContext, VerifiedFetchPluginFactory } from './types.js'
6
+
7
+ export class DirIndexHtmlPlugin extends BasePlugin {
8
+ readonly codes = [dagPbCode]
9
+ canHandle (context: PluginContext): boolean {
10
+ const { cid, pathDetails, directoryEntries } = context
11
+ if (pathDetails == null) {
12
+ return false
13
+ }
14
+ if (pathDetails.terminalElement?.type !== 'directory') {
15
+ return false
16
+ }
17
+
18
+ if (directoryEntries?.length === 0) {
19
+ return false
20
+ }
21
+
22
+ return cid.code === dagPbCode
23
+ }
24
+
25
+ async handle (context: PluginContext): Promise<Response> {
26
+ const { resource, pathDetails, directoryEntries } = context
27
+
28
+ if (pathDetails?.terminalElement == null) {
29
+ throw new Error('Path details are required')
30
+ }
31
+ if (directoryEntries == null || directoryEntries?.length === 0) {
32
+ throw new Error('Directory entries are required')
33
+ }
34
+ const terminalElement = pathDetails.terminalElement
35
+
36
+ const gatewayURL = resource
37
+ const htmlResponse = dirIndexHtml(terminalElement, directoryEntries, { gatewayURL, log: this.log })
38
+ const response = okResponse(resource, htmlResponse)
39
+ response.headers.set('content-type', 'text/html')
40
+ return response
41
+ }
42
+ }
43
+
44
+ export const dirIndexHtmlPluginFactory: VerifiedFetchPluginFactory = (opts) => new DirIndexHtmlPlugin(opts)
@@ -0,0 +1,69 @@
1
+ import { Record as DHTRecord } from '@libp2p/kad-dht'
2
+ import { Key } from 'interface-datastore'
3
+ import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
4
+ import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
5
+ import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
6
+ import { getPeerIdFromString } from '../utils/get-peer-id-from-string.js'
7
+ import { badRequestResponse, okResponse } from '../utils/responses.js'
8
+ import { PluginFatalError } from './errors.js'
9
+ import { BasePlugin } from './plugin-base.js'
10
+ import type { PluginContext } from './types.js'
11
+ import type { PeerId } from '@libp2p/interface'
12
+
13
+ /**
14
+ * Accepts an `ipns://...`, `https?://<ipnsname>.ipns.<domain>`, or `https?://<domain>/ipns/...` URL as a string and
15
+ * returns a `Response` containing a raw IPNS record.
16
+ */
17
+ export class IpnsRecordPlugin extends BasePlugin {
18
+ readonly codes = []
19
+ canHandle ({ cid, accept, query }: PluginContext): boolean {
20
+ this.log('checking if we can handle %c with accept %s', cid, accept)
21
+
22
+ return accept === 'application/vnd.ipfs.ipns-record' || query.format === 'ipns-record'
23
+ }
24
+
25
+ async handle (context: PluginContext): Promise<Response> {
26
+ const { resource, path, options } = context
27
+ const { helia } = this.pluginOptions
28
+ context.reqFormat = 'ipns-record'
29
+ if (path !== '' || !(resource.startsWith('ipns://') || resource.includes('.ipns.') || resource.includes('/ipns/'))) {
30
+ this.log.error('invalid request for IPNS name "%s" and path "%s"', resource, path)
31
+ throw new PluginFatalError('ERR_INVALID_IPNS_NAME', 'Invalid IPNS name', { response: badRequestResponse(resource, 'Invalid IPNS name') })
32
+ }
33
+ let peerId: PeerId
34
+
35
+ try {
36
+ let peerIdString: string
37
+ if (resource.startsWith('ipns://')) {
38
+ peerIdString = resource.replace('ipns://', '')
39
+ } else if (resource.includes('/ipns/')) {
40
+ peerIdString = resource.split('/ipns/')[1].split('/')[0].split('?')[0]
41
+ } else {
42
+ peerIdString = resource.split('.ipns.')[0].split('://')[1]
43
+ }
44
+
45
+ this.log.trace('trying to parse peer id from "%s"', peerIdString)
46
+ peerId = getPeerIdFromString(peerIdString)
47
+ } catch (err: any) {
48
+ this.log.error('could not parse peer id from IPNS url %s', resource, err)
49
+
50
+ throw new PluginFatalError('ERR_NO_PEER_ID_FOUND', 'could not parse peer id from url', { response: badRequestResponse(resource, err) })
51
+ }
52
+
53
+ // since this call happens after parseResource, we've already resolved the
54
+ // IPNS name so a local copy should be in the helia datastore, so we can
55
+ // just read it out..
56
+ const routingKey = uint8ArrayConcat([
57
+ uint8ArrayFromString('/ipns/'),
58
+ peerId.toMultihash().bytes
59
+ ])
60
+ const datastoreKey = new Key('/dht/record/' + uint8ArrayToString(routingKey, 'base32'), false)
61
+ const buf = await helia.datastore.get(datastoreKey, options)
62
+ const record = DHTRecord.deserialize(buf)
63
+
64
+ const response = okResponse(resource, record.value)
65
+ response.headers.set('content-type', 'application/vnd.ipfs.ipns-record')
66
+
67
+ return response
68
+ }
69
+ }
@@ -0,0 +1,57 @@
1
+ import * as ipldDagCbor from '@ipld/dag-cbor'
2
+ import * as ipldDagJson from '@ipld/dag-json'
3
+ import { code as jsonCode } from 'multiformats/codecs/json'
4
+ import { notAcceptableResponse, okResponse } from '../utils/responses.js'
5
+ import { BasePlugin } from './plugin-base.js'
6
+ import type { PluginContext } from './types.js'
7
+
8
+ /**
9
+ * Handles `dag-json` content, including requests with Accept: `application/vnd.ipld.dag-cbor` and `application/cbor`.
10
+ */
11
+ export class JsonPlugin extends BasePlugin {
12
+ readonly codes = [ipldDagJson.code, jsonCode]
13
+ canHandle ({ cid, accept }: PluginContext): boolean {
14
+ this.log('checking if we can handle %c with accept %s', cid, accept)
15
+
16
+ if (accept === 'application/vnd.ipld.dag-json' && cid.code !== ipldDagCbor.code) {
17
+ // we can handle application/vnd.ipld.dag-json, but if the CID codec is ipldDagCbor, DagCborPlugin should handle it
18
+ // TODO: remove the need for deny-listing cases in plugins
19
+ return true
20
+ }
21
+
22
+ return ipldDagJson.code === cid.code || jsonCode === cid.code
23
+ }
24
+
25
+ async handle (context: PluginContext): Promise<Response> {
26
+ const { path, resource, cid, accept, options } = context
27
+ const { getBlockstore } = this.pluginOptions
28
+ const session = options?.session ?? true
29
+
30
+ this.log.trace('fetching %c/%s', cid, path)
31
+
32
+ const terminalCid = context.pathDetails?.terminalElement.cid ?? context.cid
33
+ const blockstore = getBlockstore(terminalCid, resource, session, options)
34
+ const block = await blockstore.get(terminalCid, options)
35
+ let body: string | Uint8Array
36
+
37
+ if (accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
38
+ try {
39
+ // if vnd.ipld.dag-cbor has been specified, convert to the format - note
40
+ // that this supports more data types than regular JSON, the content-type
41
+ // response header is set so the user knows to process it differently
42
+ const obj = ipldDagJson.decode(block)
43
+ body = ipldDagCbor.encode(obj)
44
+ } catch (err) {
45
+ this.log.error('could not transform %c to application/vnd.ipld.dag-cbor', err)
46
+ return notAcceptableResponse(resource)
47
+ }
48
+ } else {
49
+ // skip decoding
50
+ body = block
51
+ }
52
+
53
+ const response = okResponse(resource, body)
54
+ response.headers.set('content-type', accept ?? 'application/json')
55
+ return response
56
+ }
57
+ }
@@ -0,0 +1,92 @@
1
+ import { code as rawCode } from 'multiformats/codecs/raw'
2
+ import { identity } from 'multiformats/hashes/identity'
3
+ import { ByteRangeContext } from '../utils/byte-range-context.js'
4
+ import { notFoundResponse, okRangeResponse } from '../utils/responses.js'
5
+ import { setContentType } from '../utils/set-content-type.js'
6
+ import { PluginFatalError } from './errors.js'
7
+ import { BasePlugin } from './plugin-base.js'
8
+ import type { PluginContext } from './types.js'
9
+
10
+ /**
11
+ * These are Accept header values that will cause content type sniffing to be
12
+ * skipped and set to these values.
13
+ */
14
+ const RAW_HEADERS = [
15
+ 'application/vnd.ipld.dag-json',
16
+ 'application/vnd.ipld.raw',
17
+ 'application/octet-stream'
18
+ ]
19
+
20
+ /**
21
+ * if the user has specified an `Accept` header, and it's in our list of
22
+ * allowable "raw" format headers, use that instead of detecting the content
23
+ * type. This avoids the user from receiving something different when they
24
+ * signal that they want to `Accept` a specific mime type.
25
+ */
26
+ function getOverridenRawContentType ({ headers, accept }: { headers?: HeadersInit, accept?: string }): string | undefined {
27
+ // accept has already been resolved by getResolvedAcceptHeader, if we have it, use it.
28
+ const acceptHeader = accept ?? new Headers(headers).get('accept') ?? ''
29
+
30
+ // e.g. "Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8"
31
+ const acceptHeaders = acceptHeader.split(',')
32
+ .map(s => s.split(';')[0])
33
+ .map(s => s.trim())
34
+
35
+ for (const mimeType of acceptHeaders) {
36
+ if (mimeType === '*/*') {
37
+ return
38
+ }
39
+
40
+ if (RAW_HEADERS.includes(mimeType ?? '')) {
41
+ return mimeType
42
+ }
43
+ }
44
+ }
45
+
46
+ export class RawPlugin extends BasePlugin {
47
+ codes: number[] = [rawCode, identity.code]
48
+
49
+ canHandle ({ cid, accept, query }: PluginContext): boolean {
50
+ this.log('checking if we can handle %c with accept %s', cid, accept)
51
+ return accept === 'application/vnd.ipld.raw' || query.format === 'raw'
52
+ }
53
+
54
+ async handle (context: PluginContext): Promise<Response> {
55
+ const { path, resource, cid, accept, query, options } = context
56
+ const { getBlockstore, contentTypeParser } = this.pluginOptions
57
+ const session = options?.session ?? true
58
+ const log = this.log
59
+
60
+ if (accept === 'application/vnd.ipld.raw' || query.format === 'raw') {
61
+ context.reqFormat = 'raw'
62
+ context.query.download = true
63
+ context.query.filename = context.query.filename ?? `${cid.toString()}.bin`
64
+ log.trace('Set content disposition...')
65
+ } else {
66
+ log.trace('Did NOT setting content disposition...')
67
+ }
68
+
69
+ if (path !== '' && cid.code === rawCode) {
70
+ log.trace('404-ing raw codec request for %c/%s', cid, path)
71
+ // throw new PluginError('ERR_RAW_PATHS_NOT_SUPPORTED', 'Raw codec does not support paths')
72
+ // return notFoundResponse(resource, 'Raw codec does not support paths')
73
+ throw new PluginFatalError('ERR_RAW_PATHS_NOT_SUPPORTED', 'Raw codec does not support paths', { response: notFoundResponse(resource, 'Raw codec does not support paths') })
74
+ }
75
+
76
+ const byteRangeContext = new ByteRangeContext(this.pluginOptions.logger, options?.headers)
77
+ const terminalCid = context.pathDetails?.terminalElement.cid ?? context.cid
78
+ const blockstore = getBlockstore(terminalCid, resource, session, options)
79
+ const result = await blockstore.get(terminalCid, options)
80
+ byteRangeContext.setBody(result)
81
+ const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log }, {
82
+ redirected: false
83
+ })
84
+
85
+ // if the user has specified an `Accept` header that corresponds to a raw
86
+ // type, honour that header, so for example they don't request
87
+ // `application/vnd.ipld.raw` but get `application/octet-stream`
88
+ await setContentType({ bytes: result, path, response, defaultContentType: getOverridenRawContentType({ headers: options?.headers, accept }), contentTypeParser, log })
89
+
90
+ return response
91
+ }
92
+ }
@@ -0,0 +1,44 @@
1
+ import { code as dagPbCode } from '@ipld/dag-pb'
2
+ import toBrowserReadableStream from 'it-to-browser-readablestream'
3
+ import { code as rawCode } from 'multiformats/codecs/raw'
4
+ import { getETag } from '../utils/get-e-tag.js'
5
+ import { tarStream } from '../utils/get-tar-stream.js'
6
+ import { notAcceptableResponse, okResponse } from '../utils/responses.js'
7
+ import { BasePlugin } from './plugin-base.js'
8
+ import type { PluginContext } from './types.js'
9
+
10
+ /**
11
+ * Accepts a UnixFS `CID` and returns a `.tar` file containing the file or
12
+ * directory structure referenced by the `CID`.
13
+ */
14
+ export class TarPlugin extends BasePlugin {
15
+ readonly codes = []
16
+ canHandle ({ cid, accept, query }: PluginContext): boolean {
17
+ this.log('checking if we can handle %c with accept %s', cid, accept)
18
+ return accept === 'application/x-tar' || query.format === 'tar'
19
+ }
20
+
21
+ async handle (context: PluginContext): Promise<Response> {
22
+ const { cid, path, resource, options, pathDetails } = context
23
+ const { getBlockstore } = this.pluginOptions
24
+
25
+ const terminusElement = pathDetails?.terminalElement.cid ?? cid
26
+ if (terminusElement.code !== dagPbCode && terminusElement.code !== rawCode) {
27
+ return notAcceptableResponse('only UnixFS data can be returned in a TAR file')
28
+ }
29
+
30
+ context.reqFormat = 'tar'
31
+ context.query.download = true
32
+ context.query.filename = context.query.filename ?? `${terminusElement.toString()}.tar`
33
+
34
+ const blockstore = getBlockstore(terminusElement, resource, options?.session, options)
35
+ const stream = toBrowserReadableStream<Uint8Array>(tarStream(`/ipfs/${cid}/${path}`, blockstore, options))
36
+
37
+ const response = okResponse(resource, stream)
38
+ response.headers.set('content-type', 'application/x-tar')
39
+
40
+ response.headers.set('etag', getETag({ cid: terminusElement, reqFormat: context.reqFormat, weak: true }))
41
+
42
+ return response
43
+ }
44
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Export extension (non-default) plugins here
3
+ */
4
+ export { DirIndexHtmlPlugin, dirIndexHtmlPluginFactory } from './plugin-handle-dir-index-html.js'
@@ -0,0 +1,73 @@
1
+ import type { PluginError } from './errors.js'
2
+ import type { VerifiedFetchInit } from '../index.js'
3
+ import type { ContentTypeParser, RequestFormatShorthand } from '../types.js'
4
+ import type { ParsedUrlStringResults } from '../utils/parse-url-string.js'
5
+ import type { PathWalkerResponse } from '../utils/walk-path.js'
6
+ import type { AbortOptions, ComponentLogger, Logger } from '@libp2p/interface'
7
+ import type { Helia } from 'helia'
8
+ import type { Blockstore } from 'interface-blockstore'
9
+ import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
10
+ import type { CID } from 'multiformats/cid'
11
+ import type { CustomProgressEvent } from 'progress-events'
12
+
13
+ /**
14
+ * Contains common components and functions required by plugins to handle a request.
15
+ * - Read-Only: Plugins can read but shouldn’t rewrite them.
16
+ * - Persistent: Relevant even after the request completes (e.g., logging or metrics).
17
+ */
18
+ export interface PluginOptions {
19
+ logger: ComponentLogger
20
+ getBlockstore(cid: CID, resource: string | CID, useSession?: boolean, options?: AbortOptions): Blockstore
21
+ handleServerTiming<T>(name: string, description: string, fn: () => Promise<T>, withServerTiming: boolean): Promise<T>
22
+ contentTypeParser?: ContentTypeParser
23
+ helia: Helia
24
+ }
25
+
26
+ /**
27
+ * Represents the ephemeral, modifiable state used by the pipeline.
28
+ * - Mutable: Evolves as you walk the plugin chain.
29
+ * - Shared Data: Allows plugins to communicate partial results, discovered data, or interim errors.
30
+ * - Ephemeral: Typically discarded once fetch(...) completes.
31
+ */
32
+ export interface PluginContext {
33
+ readonly cid: CID
34
+ readonly path: string
35
+ readonly resource: string
36
+ readonly accept?: string
37
+ /**
38
+ * The last time the context is modified, so we know whether a plugin has modified it.
39
+ * A plugin should increment this value if it modifies the context.
40
+ */
41
+ modified: number
42
+ withServerTiming?: boolean
43
+ onProgress?(evt: CustomProgressEvent<any>): void
44
+ options?: Omit<VerifiedFetchInit, 'signal'> & AbortOptions
45
+ isDirectory?: boolean
46
+ directoryEntries?: UnixFSEntry[]
47
+ errors?: PluginError[]
48
+ reqFormat?: RequestFormatShorthand
49
+ pathDetails?: PathWalkerResponse
50
+ query: ParsedUrlStringResults['query']
51
+ [key: string]: unknown
52
+ }
53
+
54
+ export interface VerifiedFetchPlugin {
55
+ readonly codes: number[]
56
+ readonly log: Logger
57
+ canHandle (context: PluginContext): boolean
58
+ handle (context: PluginContext): Promise<Response | null>
59
+ }
60
+
61
+ export interface VerifiedFetchPluginFactory {
62
+ (options: PluginOptions): VerifiedFetchPlugin
63
+ }
64
+
65
+ export interface PluginErrorOptions {
66
+ fatal?: boolean
67
+ details?: Record<string, any>
68
+ response?: Response
69
+ }
70
+
71
+ export interface FatalPluginErrorOptions extends PluginErrorOptions {
72
+ response: Response
73
+ }
package/src/types.ts CHANGED
@@ -1,36 +1,7 @@
1
- import { type AbortOptions } from '@libp2p/interface'
2
- import { type CID } from 'multiformats/cid'
3
- import type { VerifiedFetchInit } from './index.js'
4
-
5
1
  export type RequestFormatShorthand = 'raw' | 'car' | 'tar' | 'ipns-record' | 'dag-json' | 'dag-cbor' | 'json' | 'cbor'
6
2
 
7
3
  export type SupportedBodyTypes = string | ArrayBuffer | Blob | ReadableStream<Uint8Array> | null
8
4
 
9
- export interface FetchHandlerFunctionArg {
10
- cid: CID
11
- path: string
12
-
13
- /**
14
- * Whether to use a session during fetch operations
15
- *
16
- * @default true
17
- */
18
- session: boolean
19
-
20
- options?: Omit<VerifiedFetchInit, 'signal'> & AbortOptions
21
-
22
- /**
23
- * If present, the user has sent an accept header with this value - if the
24
- * content cannot be represented in this format a 406 should be returned
25
- */
26
- accept?: string
27
-
28
- /**
29
- * The originally requested resource
30
- */
31
- resource: string
32
- }
33
-
34
5
  /**
35
6
  * A ContentTypeParser attempts to return the mime type of a given file. It
36
7
  * receives the first chunk of the file data and the file name, if it is