@helia/verified-fetch 2.4.0 → 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.
- package/README.md +192 -0
- package/dist/index.min.js +357 -32
- package/dist/src/index.d.ts +198 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +192 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/plugins/errors.d.ts +25 -0
- package/dist/src/plugins/errors.d.ts.map +1 -0
- package/dist/src/plugins/errors.js +33 -0
- package/dist/src/plugins/errors.js.map +1 -0
- package/dist/src/plugins/index.d.ts +8 -0
- package/dist/src/plugins/index.d.ts.map +1 -0
- package/dist/src/plugins/index.js +7 -0
- package/dist/src/plugins/index.js.map +1 -0
- package/dist/src/plugins/plugin-base.d.ts +19 -0
- package/dist/src/plugins/plugin-base.d.ts.map +1 -0
- package/dist/src/plugins/plugin-base.js +26 -0
- package/dist/src/plugins/plugin-base.js.map +1 -0
- package/dist/src/plugins/plugin-handle-car.d.ts +11 -0
- package/dist/src/plugins/plugin-handle-car.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-car.js +28 -0
- package/dist/src/plugins/plugin-handle-car.js.map +1 -0
- package/dist/src/plugins/plugin-handle-dag-cbor.d.ts +11 -0
- package/dist/src/plugins/plugin-handle-dag-cbor.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-dag-cbor.js +73 -0
- package/dist/src/plugins/plugin-handle-dag-cbor.js.map +1 -0
- package/dist/src/plugins/plugin-handle-dag-pb.d.ts +15 -0
- package/dist/src/plugins/plugin-handle-dag-pb.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-dag-pb.js +152 -0
- package/dist/src/plugins/plugin-handle-dag-pb.js.map +1 -0
- package/dist/src/plugins/plugin-handle-dag-walk.d.ts +16 -0
- package/dist/src/plugins/plugin-handle-dag-walk.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-dag-walk.js +45 -0
- package/dist/src/plugins/plugin-handle-dag-walk.js.map +1 -0
- package/dist/src/plugins/plugin-handle-dir-index-html.d.ts +9 -0
- package/dist/src/plugins/plugin-handle-dir-index-html.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-dir-index-html.js +37 -0
- package/dist/src/plugins/plugin-handle-dir-index-html.js.map +1 -0
- package/dist/src/plugins/plugin-handle-ipns-record.d.ts +12 -0
- package/dist/src/plugins/plugin-handle-ipns-record.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-ipns-record.js +62 -0
- package/dist/src/plugins/plugin-handle-ipns-record.js.map +1 -0
- package/dist/src/plugins/plugin-handle-json.d.ts +11 -0
- package/dist/src/plugins/plugin-handle-json.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-json.js +51 -0
- package/dist/src/plugins/plugin-handle-json.js.map +1 -0
- package/dist/src/plugins/plugin-handle-raw.d.ts +8 -0
- package/dist/src/plugins/plugin-handle-raw.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-raw.js +80 -0
- package/dist/src/plugins/plugin-handle-raw.js.map +1 -0
- package/dist/src/plugins/plugin-handle-tar.d.ts +12 -0
- package/dist/src/plugins/plugin-handle-tar.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-tar.js +36 -0
- package/dist/src/plugins/plugin-handle-tar.js.map +1 -0
- package/dist/src/plugins/plugins.d.ts +5 -0
- package/dist/src/plugins/plugins.d.ts.map +1 -0
- package/dist/src/plugins/plugins.js +5 -0
- package/dist/src/plugins/plugins.js.map +1 -0
- package/dist/src/plugins/types.d.ts +68 -0
- package/dist/src/plugins/types.d.ts.map +1 -0
- package/dist/src/plugins/types.js +2 -0
- package/dist/src/plugins/types.js.map +1 -0
- package/dist/src/types.d.ts +0 -27
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +1 -2
- package/dist/src/types.js.map +1 -1
- package/dist/src/utils/dir-index-html.d.ts +16 -0
- package/dist/src/utils/dir-index-html.d.ts.map +1 -0
- package/dist/src/utils/dir-index-html.js +387 -0
- package/dist/src/utils/dir-index-html.js.map +1 -0
- package/dist/src/utils/get-e-tag.d.ts +1 -1
- package/dist/src/utils/get-e-tag.d.ts.map +1 -1
- package/dist/src/utils/get-e-tag.js +18 -3
- package/dist/src/utils/get-e-tag.js.map +1 -1
- package/dist/src/utils/walk-path.d.ts +3 -2
- package/dist/src/utils/walk-path.d.ts.map +1 -1
- package/dist/src/utils/walk-path.js +1 -1
- package/dist/src/utils/walk-path.js.map +1 -1
- package/dist/src/verified-fetch.d.ts +6 -24
- package/dist/src/verified-fetch.d.ts.map +1 -1
- package/dist/src/verified-fetch.js +164 -387
- package/dist/src/verified-fetch.js.map +1 -1
- package/dist/typedoc-urls.json +32 -24
- package/package.json +6 -2
- package/src/index.ts +199 -0
- package/src/plugins/errors.ts +37 -0
- package/src/plugins/index.ts +8 -0
- package/src/plugins/plugin-base.ts +30 -0
- package/src/plugins/plugin-handle-car.ts +32 -0
- package/src/plugins/plugin-handle-dag-cbor.ts +84 -0
- package/src/plugins/plugin-handle-dag-pb.ts +168 -0
- package/src/plugins/plugin-handle-dag-walk.ts +53 -0
- package/src/plugins/plugin-handle-dir-index-html.ts +44 -0
- package/src/plugins/plugin-handle-ipns-record.ts +69 -0
- package/src/plugins/plugin-handle-json.ts +57 -0
- package/src/plugins/plugin-handle-raw.ts +92 -0
- package/src/plugins/plugin-handle-tar.ts +44 -0
- package/src/plugins/plugins.ts +4 -0
- package/src/plugins/types.ts +73 -0
- package/src/types.ts +0 -34
- package/src/utils/dir-index-html.ts +445 -0
- package/src/utils/get-e-tag.ts +20 -3
- package/src/utils/walk-path.ts +3 -3
- package/src/verified-fetch.ts +187 -430
package/src/verified-fetch.ts
CHANGED
|
@@ -1,42 +1,31 @@
|
|
|
1
|
-
import { car } from '@helia/car'
|
|
2
1
|
import { ipns as heliaIpns, type IPNS } from '@helia/ipns'
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import { code as dagPbCode } from '@ipld/dag-pb'
|
|
6
|
-
import { type AbortOptions, type Logger, type PeerId } from '@libp2p/interface'
|
|
7
|
-
import { Record as DHTRecord } from '@libp2p/kad-dht'
|
|
8
|
-
import { Key } from 'interface-datastore'
|
|
9
|
-
import { exporter, type ObjectNode } from 'ipfs-unixfs-exporter'
|
|
10
|
-
import toBrowserReadableStream from 'it-to-browser-readablestream'
|
|
2
|
+
import { type AbortOptions, type Logger } from '@libp2p/interface'
|
|
3
|
+
import { prefixLogger } from '@libp2p/logger'
|
|
11
4
|
import { LRUCache } from 'lru-cache'
|
|
12
5
|
import { type CID } from 'multiformats/cid'
|
|
13
|
-
import { code as jsonCode } from 'multiformats/codecs/json'
|
|
14
|
-
import { code as rawCode } from 'multiformats/codecs/raw'
|
|
15
|
-
import { identity } from 'multiformats/hashes/identity'
|
|
16
6
|
import { CustomProgressEvent } from 'progress-events'
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
7
|
+
import { CarPlugin } from './plugins/plugin-handle-car.js'
|
|
8
|
+
import { DagCborPlugin } from './plugins/plugin-handle-dag-cbor.js'
|
|
9
|
+
import { DagPbPlugin } from './plugins/plugin-handle-dag-pb.js'
|
|
10
|
+
import { DagWalkPlugin } from './plugins/plugin-handle-dag-walk.js'
|
|
11
|
+
import { IpnsRecordPlugin } from './plugins/plugin-handle-ipns-record.js'
|
|
12
|
+
import { JsonPlugin } from './plugins/plugin-handle-json.js'
|
|
13
|
+
import { RawPlugin } from './plugins/plugin-handle-raw.js'
|
|
14
|
+
import { TarPlugin } from './plugins/plugin-handle-tar.js'
|
|
22
15
|
import { getContentDispositionFilename } from './utils/get-content-disposition-filename.js'
|
|
23
16
|
import { getETag } from './utils/get-e-tag.js'
|
|
24
|
-
import { getPeerIdFromString } from './utils/get-peer-id-from-string.js'
|
|
25
17
|
import { getResolvedAcceptHeader } from './utils/get-resolved-accept-header.js'
|
|
26
|
-
import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterable.js'
|
|
27
|
-
import { tarStream } from './utils/get-tar-stream.js'
|
|
28
18
|
import { getRedirectResponse } from './utils/handle-redirects.js'
|
|
29
19
|
import { parseResource } from './utils/parse-resource.js'
|
|
30
20
|
import { type ParsedUrlStringResults } from './utils/parse-url-string.js'
|
|
31
21
|
import { resourceToSessionCacheKey } from './utils/resource-to-cache-key.js'
|
|
32
|
-
import { setCacheControlHeader
|
|
33
|
-
import { badRequestResponse,
|
|
22
|
+
import { setCacheControlHeader } from './utils/response-headers.js'
|
|
23
|
+
import { badRequestResponse, notAcceptableResponse, notSupportedResponse, badGatewayResponse } from './utils/responses.js'
|
|
34
24
|
import { selectOutputType } from './utils/select-output-type.js'
|
|
35
25
|
import { serverTiming } from './utils/server-timing.js'
|
|
36
|
-
import { setContentType } from './utils/set-content-type.js'
|
|
37
|
-
import { handlePathWalking, isObjectNode } from './utils/walk-path.js'
|
|
38
26
|
import type { CIDDetail, ContentTypeParser, CreateVerifiedFetchOptions, Resource, ResourceDetail, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
|
|
39
|
-
import type {
|
|
27
|
+
import type { VerifiedFetchPlugin, PluginContext, PluginOptions } from './plugins/types.js'
|
|
28
|
+
import type { RequestFormatShorthand } from './types.js'
|
|
40
29
|
import type { Helia, SessionBlockstore } from '@helia/interface'
|
|
41
30
|
import type { Blockstore } from 'interface-blockstore'
|
|
42
31
|
|
|
@@ -48,10 +37,6 @@ interface VerifiedFetchComponents {
|
|
|
48
37
|
ipns?: IPNS
|
|
49
38
|
}
|
|
50
39
|
|
|
51
|
-
interface FetchHandlerFunction {
|
|
52
|
-
(options: FetchHandlerFunctionArg): Promise<Response>
|
|
53
|
-
}
|
|
54
|
-
|
|
55
40
|
function convertOptions (options?: VerifiedFetchOptions): (Omit<VerifiedFetchOptions, 'signal'> & AbortOptions) | undefined {
|
|
56
41
|
if (options == null) {
|
|
57
42
|
return undefined
|
|
@@ -70,40 +55,14 @@ function convertOptions (options?: VerifiedFetchOptions): (Omit<VerifiedFetchOpt
|
|
|
70
55
|
}
|
|
71
56
|
}
|
|
72
57
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
'
|
|
79
|
-
|
|
80
|
-
'
|
|
81
|
-
]
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* if the user has specified an `Accept` header, and it's in our list of
|
|
85
|
-
* allowable "raw" format headers, use that instead of detecting the content
|
|
86
|
-
* type. This avoids the user from receiving something different when they
|
|
87
|
-
* signal that they want to `Accept` a specific mime type.
|
|
88
|
-
*/
|
|
89
|
-
function getOverridenRawContentType ({ headers, accept }: { headers?: HeadersInit, accept?: string }): string | undefined {
|
|
90
|
-
// accept has already been resolved by getResolvedAcceptHeader, if we have it, use it.
|
|
91
|
-
const acceptHeader = accept ?? new Headers(headers).get('accept') ?? ''
|
|
92
|
-
|
|
93
|
-
// e.g. "Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8"
|
|
94
|
-
const acceptHeaders = acceptHeader.split(',')
|
|
95
|
-
.map(s => s.split(';')[0])
|
|
96
|
-
.map(s => s.trim())
|
|
97
|
-
|
|
98
|
-
for (const mimeType of acceptHeaders) {
|
|
99
|
-
if (mimeType === '*/*') {
|
|
100
|
-
return
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (RAW_HEADERS.includes(mimeType ?? '')) {
|
|
104
|
-
return mimeType
|
|
105
|
-
}
|
|
106
|
-
}
|
|
58
|
+
// TODO: merge/combine with PluginContext
|
|
59
|
+
interface FinalResponseContext {
|
|
60
|
+
cid?: ParsedUrlStringResults['cid']
|
|
61
|
+
reqFormat?: RequestFormatShorthand
|
|
62
|
+
ttl?: ParsedUrlStringResults['ttl']
|
|
63
|
+
protocol?: ParsedUrlStringResults['protocol']
|
|
64
|
+
ipfsPath?: string
|
|
65
|
+
query?: ParsedUrlStringResults['query']
|
|
107
66
|
}
|
|
108
67
|
|
|
109
68
|
export class VerifiedFetch {
|
|
@@ -114,6 +73,7 @@ export class VerifiedFetch {
|
|
|
114
73
|
private readonly blockstoreSessions: LRUCache<string, SessionBlockstore>
|
|
115
74
|
private serverTimingHeaders: string[] = []
|
|
116
75
|
private readonly withServerTiming: boolean
|
|
76
|
+
private readonly plugins: VerifiedFetchPlugin[] = []
|
|
117
77
|
|
|
118
78
|
constructor ({ helia, ipns }: VerifiedFetchComponents, init?: CreateVerifiedFetchOptions) {
|
|
119
79
|
this.helia = helia
|
|
@@ -128,10 +88,46 @@ export class VerifiedFetch {
|
|
|
128
88
|
}
|
|
129
89
|
})
|
|
130
90
|
this.withServerTiming = init?.withServerTiming ?? false
|
|
91
|
+
|
|
92
|
+
const pluginOptions: PluginOptions = {
|
|
93
|
+
...init,
|
|
94
|
+
logger: prefixLogger('helia:verified-fetch'),
|
|
95
|
+
getBlockstore: (cid, resource, useSession, options) => this.getBlockstore(cid, resource, useSession, options),
|
|
96
|
+
handleServerTiming: async (name, description, fn) => this.handleServerTiming(name, description, fn, this.withServerTiming),
|
|
97
|
+
helia,
|
|
98
|
+
contentTypeParser: this.contentTypeParser
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const defaultPlugins = [
|
|
102
|
+
new DagWalkPlugin(pluginOptions),
|
|
103
|
+
new IpnsRecordPlugin(pluginOptions),
|
|
104
|
+
new CarPlugin(pluginOptions),
|
|
105
|
+
new RawPlugin(pluginOptions),
|
|
106
|
+
new TarPlugin(pluginOptions),
|
|
107
|
+
new JsonPlugin(pluginOptions),
|
|
108
|
+
new DagCborPlugin(pluginOptions),
|
|
109
|
+
new DagPbPlugin(pluginOptions)
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
const customPlugins = init?.plugins?.map((pluginFactory) => pluginFactory(pluginOptions)) ?? []
|
|
113
|
+
|
|
114
|
+
if (customPlugins.length > 0) {
|
|
115
|
+
// allow custom plugins to replace default plugins
|
|
116
|
+
const defaultPluginMap = new Map(defaultPlugins.map(plugin => [plugin.constructor.name, plugin]))
|
|
117
|
+
const customPluginMap = new Map(customPlugins.map(plugin => [plugin.constructor.name, plugin]))
|
|
118
|
+
|
|
119
|
+
this.plugins = defaultPlugins.map(plugin => customPluginMap.get(plugin.constructor.name) ?? plugin)
|
|
120
|
+
|
|
121
|
+
// Add any remaining custom plugins that don't replace a default plugin
|
|
122
|
+
this.plugins.push(...customPlugins.filter(plugin => !defaultPluginMap.has(plugin.constructor.name)))
|
|
123
|
+
} else {
|
|
124
|
+
this.plugins = defaultPlugins
|
|
125
|
+
}
|
|
126
|
+
|
|
131
127
|
this.log.trace('created VerifiedFetch instance')
|
|
132
128
|
}
|
|
133
129
|
|
|
134
|
-
private getBlockstore (root: CID, resource: string | CID, useSession: boolean, options
|
|
130
|
+
private getBlockstore (root: CID, resource: string | CID, useSession: boolean = true, options: AbortOptions = {}): Blockstore {
|
|
135
131
|
const key = resourceToSessionCacheKey(resource)
|
|
136
132
|
if (!useSession) {
|
|
137
133
|
return this.helia.blockstore
|
|
@@ -147,345 +143,154 @@ export class VerifiedFetch {
|
|
|
147
143
|
return session
|
|
148
144
|
}
|
|
149
145
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
*/
|
|
154
|
-
private async handleIPNSRecord ({ resource, cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
|
|
155
|
-
if (path !== '' || !(resource.startsWith('ipns://') || resource.includes('.ipns.'))) {
|
|
156
|
-
this.log.error('invalid request for IPNS name "%s" and path "%s"', resource, path)
|
|
157
|
-
return badRequestResponse(resource, 'Invalid IPNS name')
|
|
146
|
+
private async handleServerTiming<T> (name: string, description: string, fn: () => Promise<T>, withServerTiming: boolean): Promise<T> {
|
|
147
|
+
if (!withServerTiming) {
|
|
148
|
+
return fn()
|
|
158
149
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (resource.startsWith('ipns://')) {
|
|
164
|
-
const peerIdString = resource.replace('ipns://', '')
|
|
165
|
-
this.log.trace('trying to parse peer id from "%s"', peerIdString)
|
|
166
|
-
peerId = getPeerIdFromString(peerIdString)
|
|
167
|
-
} else {
|
|
168
|
-
const peerIdString = resource.split('.ipns.')[0].split('://')[1]
|
|
169
|
-
this.log.trace('trying to parse peer id from "%s"', peerIdString)
|
|
170
|
-
peerId = getPeerIdFromString(peerIdString)
|
|
171
|
-
}
|
|
172
|
-
} catch (err: any) {
|
|
173
|
-
this.log.error('could not parse peer id from IPNS url %s', resource, err)
|
|
174
|
-
|
|
175
|
-
return badRequestResponse(resource, err)
|
|
150
|
+
const { error, result, header } = await serverTiming(name, description, fn)
|
|
151
|
+
this.serverTimingHeaders.push(header)
|
|
152
|
+
if (error != null) {
|
|
153
|
+
throw error
|
|
176
154
|
}
|
|
177
155
|
|
|
178
|
-
|
|
179
|
-
// IPNS name so a local copy should be in the helia datastore, so we can
|
|
180
|
-
// just read it out..
|
|
181
|
-
const routingKey = uint8ArrayConcat([
|
|
182
|
-
uint8ArrayFromString('/ipns/'),
|
|
183
|
-
peerId.toMultihash().bytes
|
|
184
|
-
])
|
|
185
|
-
const datastoreKey = new Key('/dht/record/' + uint8ArrayToString(routingKey, 'base32'), false)
|
|
186
|
-
const buf = await this.helia.datastore.get(datastoreKey, options)
|
|
187
|
-
const record = DHTRecord.deserialize(buf)
|
|
188
|
-
|
|
189
|
-
const response = okResponse(resource, record.value)
|
|
190
|
-
response.headers.set('content-type', 'application/vnd.ipfs.ipns-record')
|
|
191
|
-
|
|
192
|
-
return response
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Accepts a `CID` and returns a `Response` with a body stream that is a CAR
|
|
197
|
-
* of the `DAG` referenced by the `CID`.
|
|
198
|
-
*/
|
|
199
|
-
private async handleCar ({ resource, cid, session, options }: FetchHandlerFunctionArg): Promise<Response> {
|
|
200
|
-
const blockstore = this.getBlockstore(cid, resource, session, options)
|
|
201
|
-
const c = car({ blockstore, getCodec: this.helia.getCodec })
|
|
202
|
-
const stream = toBrowserReadableStream(c.stream(cid, options))
|
|
203
|
-
|
|
204
|
-
const response = okResponse(resource, stream)
|
|
205
|
-
response.headers.set('content-type', 'application/vnd.ipld.car; version=1')
|
|
206
|
-
|
|
207
|
-
return response
|
|
156
|
+
return result
|
|
208
157
|
}
|
|
209
158
|
|
|
210
159
|
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
160
|
+
* The last place a Response touches in verified-fetch before being returned to the user. This is where we add the
|
|
161
|
+
* Server-Timing header to the response if it has been collected. It should be used for any final processing of the
|
|
162
|
+
* response before it is returned to the user.
|
|
213
163
|
*/
|
|
214
|
-
private
|
|
215
|
-
if (
|
|
216
|
-
|
|
164
|
+
private handleFinalResponse (response: Response, { query, cid, reqFormat, ttl, protocol, ipfsPath }: FinalResponseContext = {}): Response {
|
|
165
|
+
if (this.serverTimingHeaders.length > 0) {
|
|
166
|
+
const headerString = this.serverTimingHeaders.join(', ')
|
|
167
|
+
response.headers.set('Server-Timing', headerString)
|
|
168
|
+
this.serverTimingHeaders = []
|
|
217
169
|
}
|
|
218
170
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const response = okResponse(resource, stream)
|
|
223
|
-
response.headers.set('content-type', 'application/x-tar')
|
|
171
|
+
// set Content-Disposition header
|
|
172
|
+
let contentDisposition: string | undefined
|
|
224
173
|
|
|
225
|
-
|
|
226
|
-
}
|
|
174
|
+
this.log.trace('checking for content disposition')
|
|
227
175
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const block = await blockstore.get(cid, options)
|
|
232
|
-
let body: string | Uint8Array
|
|
233
|
-
|
|
234
|
-
if (accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
|
|
235
|
-
try {
|
|
236
|
-
// if vnd.ipld.dag-cbor has been specified, convert to the format - note
|
|
237
|
-
// that this supports more data types than regular JSON, the content-type
|
|
238
|
-
// response header is set so the user knows to process it differently
|
|
239
|
-
const obj = ipldDagJson.decode(block)
|
|
240
|
-
body = ipldDagCbor.encode(obj)
|
|
241
|
-
} catch (err) {
|
|
242
|
-
this.log.error('could not transform %c to application/vnd.ipld.dag-cbor', err)
|
|
243
|
-
return notAcceptableResponse(resource)
|
|
244
|
-
}
|
|
176
|
+
// force download if requested
|
|
177
|
+
if (query?.download === true) {
|
|
178
|
+
contentDisposition = 'attachment'
|
|
245
179
|
} else {
|
|
246
|
-
|
|
247
|
-
body = block
|
|
180
|
+
this.log.trace('download not requested')
|
|
248
181
|
}
|
|
249
182
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
private async handleDagCbor ({ resource, cid, path, accept, session, options, withServerTiming }: FetchHandlerFunctionArg): Promise<Response> {
|
|
256
|
-
this.log.trace('fetching %c/%s', cid, path)
|
|
257
|
-
let terminalElement: ObjectNode
|
|
258
|
-
const blockstore = this.getBlockstore(cid, resource, session, options)
|
|
259
|
-
|
|
260
|
-
// need to walk path, if it exists, to get the terminal element
|
|
261
|
-
const pathDetails = await this.handleServerTiming('path-walking', '', async () => handlePathWalking({ cid, path, resource, options, blockstore, log: this.log, withServerTiming }), withServerTiming)
|
|
183
|
+
// override filename if requested
|
|
184
|
+
if (query?.filename != null) {
|
|
185
|
+
if (contentDisposition == null) {
|
|
186
|
+
contentDisposition = 'inline'
|
|
187
|
+
}
|
|
262
188
|
|
|
263
|
-
|
|
264
|
-
return pathDetails
|
|
265
|
-
}
|
|
266
|
-
const ipfsRoots = pathDetails.ipfsRoots
|
|
267
|
-
if (isObjectNode(pathDetails.terminalElement)) {
|
|
268
|
-
terminalElement = pathDetails.terminalElement
|
|
189
|
+
contentDisposition = `${contentDisposition}; ${getContentDispositionFilename(query.filename)}`
|
|
269
190
|
} else {
|
|
270
|
-
|
|
271
|
-
this.log.error('terminal element is not a dag-cbor node')
|
|
272
|
-
return notSupportedResponse(resource, 'Terminal element is not a dag-cbor node')
|
|
191
|
+
this.log.trace('no filename specified in query')
|
|
273
192
|
}
|
|
274
193
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
let body: string | Uint8Array
|
|
278
|
-
|
|
279
|
-
if (accept === 'application/octet-stream' || accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
|
|
280
|
-
// skip decoding
|
|
281
|
-
body = block
|
|
282
|
-
} else if (accept === 'application/vnd.ipld.dag-json') {
|
|
283
|
-
try {
|
|
284
|
-
// if vnd.ipld.dag-json has been specified, convert to the format - note
|
|
285
|
-
// that this supports more data types than regular JSON, the content-type
|
|
286
|
-
// response header is set so the user knows to process it differently
|
|
287
|
-
const obj = ipldDagCbor.decode(block)
|
|
288
|
-
body = ipldDagJson.encode(obj)
|
|
289
|
-
} catch (err) {
|
|
290
|
-
this.log.error('could not transform %c to application/vnd.ipld.dag-json', err)
|
|
291
|
-
return notAcceptableResponse(resource)
|
|
292
|
-
}
|
|
194
|
+
if (contentDisposition != null) {
|
|
195
|
+
response.headers.set('Content-Disposition', contentDisposition)
|
|
293
196
|
} else {
|
|
294
|
-
|
|
295
|
-
body = dagCborToSafeJSON(block)
|
|
296
|
-
} catch (err) {
|
|
297
|
-
if (accept === 'application/json') {
|
|
298
|
-
this.log('could not decode DAG-CBOR as JSON-safe, but the client sent "Accept: application/json"', err)
|
|
299
|
-
|
|
300
|
-
return notAcceptableResponse(resource)
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
this.log('could not decode DAG-CBOR as JSON-safe, falling back to `application/octet-stream`', err)
|
|
304
|
-
body = block
|
|
305
|
-
}
|
|
197
|
+
this.log.trace('no content disposition specified')
|
|
306
198
|
}
|
|
307
199
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (accept == null) {
|
|
311
|
-
accept = body instanceof Uint8Array ? 'application/octet-stream' : 'application/json'
|
|
200
|
+
if (cid != null && response.headers.get('etag') == null) {
|
|
201
|
+
response.headers.set('etag', getETag({ cid, reqFormat, weak: false }))
|
|
312
202
|
}
|
|
313
203
|
|
|
314
|
-
|
|
315
|
-
|
|
204
|
+
if (protocol != null) {
|
|
205
|
+
setCacheControlHeader({ response, ttl, protocol })
|
|
206
|
+
}
|
|
207
|
+
if (ipfsPath != null) {
|
|
208
|
+
response.headers.set('X-Ipfs-Path', ipfsPath)
|
|
209
|
+
}
|
|
316
210
|
|
|
317
211
|
return response
|
|
318
212
|
}
|
|
319
213
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
214
|
+
/**
|
|
215
|
+
* Runs plugins in a loop. After each plugin that returns `null` (partial/no final),
|
|
216
|
+
* we re-check `canHandle()` for all plugins in the next iteration if the context changed.
|
|
217
|
+
*/
|
|
218
|
+
private async runPluginPipeline (context: PluginContext, maxPasses: number = 3): Promise<Response | undefined> {
|
|
219
|
+
let finalResponse: Response | undefined
|
|
220
|
+
let passCount = 0
|
|
221
|
+
const pluginsUsed = new Set<string>()
|
|
222
|
+
|
|
223
|
+
let prevModificationId = context.modified
|
|
224
|
+
|
|
225
|
+
while (passCount < maxPasses) {
|
|
226
|
+
this.log(`Starting pipeline pass #${passCount + 1}`)
|
|
227
|
+
passCount++
|
|
228
|
+
|
|
229
|
+
// gather plugins that say they can handle the *current* context, but haven't been used yet
|
|
230
|
+
const readyPlugins = this.plugins.filter(p => !pluginsUsed.has(p.constructor.name)).filter(p => p.canHandle(context))
|
|
231
|
+
if (readyPlugins.length === 0) {
|
|
232
|
+
this.log.trace('No plugins can handle the current context.. checking by CID code')
|
|
233
|
+
const plugins = this.plugins.filter(p => p.codes.includes(context.cid.code))
|
|
234
|
+
if (plugins.length > 0) {
|
|
235
|
+
readyPlugins.push(...plugins)
|
|
236
|
+
} else {
|
|
237
|
+
this.log.trace('No plugins found that can handle request by CID code; exiting pipeline.')
|
|
238
|
+
break
|
|
239
|
+
}
|
|
240
|
+
}
|
|
325
241
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
242
|
+
this.log.trace('Plugins ready to handle request: ', readyPlugins.map(p => p.constructor.name).join(', '))
|
|
243
|
+
|
|
244
|
+
// track if any plugin changed the context or returned a response
|
|
245
|
+
let contextChanged = false
|
|
246
|
+
let pluginHandled = false
|
|
247
|
+
|
|
248
|
+
for (const plugin of readyPlugins) {
|
|
249
|
+
try {
|
|
250
|
+
this.log.trace('Invoking plugin:', plugin.constructor.name)
|
|
251
|
+
pluginsUsed.add(plugin.constructor.name)
|
|
252
|
+
|
|
253
|
+
const maybeResponse = await plugin.handle(context)
|
|
254
|
+
if (maybeResponse != null) {
|
|
255
|
+
// if a plugin returns a final Response, short-circuit
|
|
256
|
+
finalResponse = maybeResponse
|
|
257
|
+
pluginHandled = true
|
|
258
|
+
break
|
|
259
|
+
}
|
|
260
|
+
} catch (err: any) {
|
|
261
|
+
context.options?.signal?.throwIfAborted()
|
|
262
|
+
this.log.error('Error in plugin:', plugin.constructor.name, err)
|
|
263
|
+
// if fatal, short-circuit the pipeline
|
|
264
|
+
if (err.name === 'PluginFatalError') {
|
|
265
|
+
// if plugin provides a custom error response, return it
|
|
266
|
+
return err.response ?? badGatewayResponse(context.resource, 'Failed to fetch')
|
|
267
|
+
}
|
|
268
|
+
} finally {
|
|
269
|
+
// on each plugin call, check for changes in the context
|
|
270
|
+
const newModificationId = context.modified
|
|
271
|
+
contextChanged = newModificationId !== prevModificationId
|
|
272
|
+
if (contextChanged) {
|
|
273
|
+
prevModificationId = newModificationId
|
|
274
|
+
}
|
|
345
275
|
}
|
|
346
276
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
277
|
+
if (finalResponse != null) {
|
|
278
|
+
this.log.trace('Plugin produced final response:', plugin.constructor.name)
|
|
279
|
+
break
|
|
280
|
+
}
|
|
350
281
|
}
|
|
351
282
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
this.log.trace('found directory at %c/%s, looking for index.html', cid, path)
|
|
355
|
-
|
|
356
|
-
const entry = await this.handleServerTiming('exporter-dir', '', async () => exporter(`/ipfs/${dirCid}/${rootFilePath}`, this.helia.blockstore, {
|
|
357
|
-
signal: options?.signal,
|
|
358
|
-
onProgress: options?.onProgress
|
|
359
|
-
}), withServerTiming)
|
|
360
|
-
|
|
361
|
-
this.log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, entry.cid)
|
|
362
|
-
path = rootFilePath
|
|
363
|
-
resolvedCID = entry.cid
|
|
364
|
-
} catch (err: any) {
|
|
365
|
-
options?.signal?.throwIfAborted()
|
|
366
|
-
this.log('error loading path %c/%s', dirCid, rootFilePath, err)
|
|
367
|
-
return notSupportedResponse('Unable to find index.html for directory at given path. Support for directories with implicit root is not implemented')
|
|
368
|
-
} finally {
|
|
369
|
-
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: dirCid, path: rootFilePath }))
|
|
283
|
+
if (pluginHandled && finalResponse != null) {
|
|
284
|
+
break
|
|
370
285
|
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// we have a validRangeRequest & terminalElement is a file, we know the size and should set it
|
|
374
|
-
if (byteRangeContext.isRangeRequest && byteRangeContext.isValidRangeRequest && terminalElement.type === 'file') {
|
|
375
|
-
byteRangeContext.setFileSize(terminalElement.unixfs.fileSize())
|
|
376
|
-
|
|
377
|
-
this.log.trace('fileSize for rangeRequest %d', byteRangeContext.getFileSize())
|
|
378
|
-
}
|
|
379
|
-
const offset = byteRangeContext.offset
|
|
380
|
-
const length = byteRangeContext.length
|
|
381
|
-
this.log.trace('calling exporter for %c/%s with offset=%o & length=%o', resolvedCID, path, offset, length)
|
|
382
286
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
onProgress: options?.onProgress
|
|
387
|
-
}), withServerTiming)
|
|
388
|
-
|
|
389
|
-
const asyncIter = entry.content({
|
|
390
|
-
signal: options?.signal,
|
|
391
|
-
onProgress: options?.onProgress,
|
|
392
|
-
offset,
|
|
393
|
-
length
|
|
394
|
-
})
|
|
395
|
-
this.log('got async iterator for %c/%s', cid, path)
|
|
396
|
-
|
|
397
|
-
const { stream, firstChunk } = await this.handleServerTiming('stream-and-chunk', '', async () => getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
|
|
398
|
-
onProgress: options?.onProgress,
|
|
399
|
-
signal: options?.signal
|
|
400
|
-
}), withServerTiming)
|
|
401
|
-
|
|
402
|
-
byteRangeContext.setBody(stream)
|
|
403
|
-
// if not a valid range request, okRangeRequest will call okResponse
|
|
404
|
-
const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log: this.log }, {
|
|
405
|
-
redirected
|
|
406
|
-
})
|
|
407
|
-
|
|
408
|
-
await this.handleServerTiming('set-content-type', '', async () => setContentType({ bytes: firstChunk, path, response, contentTypeParser: this.contentTypeParser, log: this.log }), withServerTiming)
|
|
409
|
-
|
|
410
|
-
setIpfsRoots(response, ipfsRoots)
|
|
411
|
-
|
|
412
|
-
return response
|
|
413
|
-
} catch (err: any) {
|
|
414
|
-
options?.signal?.throwIfAborted()
|
|
415
|
-
this.log.error('error streaming %c/%s', cid, path, err)
|
|
416
|
-
if (byteRangeContext.isRangeRequest && err.code === 'ERR_INVALID_PARAMS') {
|
|
417
|
-
return badRangeResponse(resource)
|
|
287
|
+
if (!contextChanged) {
|
|
288
|
+
this.log.trace('No context changes and no final response; exiting pipeline.')
|
|
289
|
+
break
|
|
418
290
|
}
|
|
419
|
-
return badGatewayResponse(resource.toString(), 'Unable to stream content')
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
private async handleRaw ({ resource, cid, path, session, options, accept }: FetchHandlerFunctionArg): Promise<Response> {
|
|
424
|
-
/**
|
|
425
|
-
* if we have a path, we can't walk it, so we need to return a 404.
|
|
426
|
-
*
|
|
427
|
-
* @see https://github.com/ipfs/gateway-conformance/blob/26994cfb056b717a23bf694ce4e94386728748dd/tests/subdomain_gateway_ipfs_test.go#L198-L204
|
|
428
|
-
*/
|
|
429
|
-
if (path !== '') {
|
|
430
|
-
this.log.trace('404-ing raw codec request for %c/%s', cid, path)
|
|
431
|
-
return notFoundResponse(resource, 'Raw codec does not support paths')
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers)
|
|
435
|
-
const blockstore = this.getBlockstore(cid, resource, session, options)
|
|
436
|
-
const result = await blockstore.get(cid, options)
|
|
437
|
-
byteRangeContext.setBody(result)
|
|
438
|
-
const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log: this.log }, {
|
|
439
|
-
redirected: false
|
|
440
|
-
})
|
|
441
|
-
|
|
442
|
-
// if the user has specified an `Accept` header that corresponds to a raw
|
|
443
|
-
// type, honour that header, so for example they don't request
|
|
444
|
-
// `application/vnd.ipld.raw` but get `application/octet-stream`
|
|
445
|
-
await setContentType({ bytes: result, path, response, defaultContentType: getOverridenRawContentType({ headers: options?.headers, accept }), contentTypeParser: this.contentTypeParser, log: this.log })
|
|
446
|
-
|
|
447
|
-
return response
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* If the user has not specified an Accept header or format query string arg,
|
|
452
|
-
* use the CID codec to choose an appropriate handler for the block data.
|
|
453
|
-
*/
|
|
454
|
-
private readonly codecHandlers: Record<number, FetchHandlerFunction> = {
|
|
455
|
-
[dagPbCode]: this.handleDagPb,
|
|
456
|
-
[ipldDagJson.code]: this.handleJson,
|
|
457
|
-
[jsonCode]: this.handleJson,
|
|
458
|
-
[ipldDagCbor.code]: this.handleDagCbor,
|
|
459
|
-
[rawCode]: this.handleRaw,
|
|
460
|
-
[identity.code]: this.handleRaw
|
|
461
|
-
}
|
|
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
291
|
}
|
|
487
292
|
|
|
488
|
-
return
|
|
293
|
+
return finalResponse
|
|
489
294
|
}
|
|
490
295
|
|
|
491
296
|
/**
|
|
@@ -529,87 +334,39 @@ export class VerifiedFetch {
|
|
|
529
334
|
|
|
530
335
|
const acceptHeader = getResolvedAcceptHeader({ query, headers: options?.headers, logger: this.helia.logger })
|
|
531
336
|
|
|
532
|
-
const accept = selectOutputType(cid, acceptHeader)
|
|
337
|
+
const accept: string | undefined = selectOutputType(cid, acceptHeader)
|
|
533
338
|
this.log('output type %s', accept)
|
|
534
339
|
|
|
535
340
|
if (acceptHeader != null && accept == null) {
|
|
536
341
|
return this.handleFinalResponse(notAcceptableResponse(resource.toString()))
|
|
537
342
|
}
|
|
538
343
|
|
|
539
|
-
|
|
540
|
-
let reqFormat: RequestFormatShorthand | undefined
|
|
344
|
+
const responseContentType: string = accept?.split(';')[0] ?? 'application/octet-stream'
|
|
541
345
|
|
|
542
346
|
const redirectResponse = await getRedirectResponse({ resource, options, logger: this.helia.logger, cid })
|
|
543
347
|
if (redirectResponse != null) {
|
|
544
348
|
return this.handleFinalResponse(redirectResponse)
|
|
545
349
|
}
|
|
546
350
|
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
if (accept === 'application/vnd.ipfs.ipns-record') {
|
|
550
|
-
// the user requested a raw IPNS record
|
|
551
|
-
reqFormat = 'ipns-record'
|
|
552
|
-
response = await this.handleIPNSRecord(handlerArgs)
|
|
553
|
-
} else if (accept === 'application/vnd.ipld.car') {
|
|
554
|
-
// the user requested a CAR file
|
|
555
|
-
reqFormat = 'car'
|
|
556
|
-
query.download = true
|
|
557
|
-
query.filename = query.filename ?? `${cid.toString()}.car`
|
|
558
|
-
response = await this.handleCar(handlerArgs)
|
|
559
|
-
} else if (accept === 'application/vnd.ipld.raw') {
|
|
560
|
-
// the user requested a raw block
|
|
561
|
-
reqFormat = 'raw'
|
|
562
|
-
query.download = true
|
|
563
|
-
query.filename = query.filename ?? `${cid.toString()}.bin`
|
|
564
|
-
response = await this.handleRaw(handlerArgs)
|
|
565
|
-
} else if (accept === 'application/x-tar') {
|
|
566
|
-
// the user requested a TAR file
|
|
567
|
-
reqFormat = 'tar'
|
|
568
|
-
query.download = true
|
|
569
|
-
query.filename = query.filename ?? `${cid.toString()}.tar`
|
|
570
|
-
response = await this.handleTar(handlerArgs)
|
|
571
|
-
} else {
|
|
572
|
-
this.log.trace('finding handler for cid code "%s" and output type "%s"', cid.code, accept)
|
|
573
|
-
// derive the handler from the CID type
|
|
574
|
-
const codecHandler = this.codecHandlers[cid.code]
|
|
575
|
-
|
|
576
|
-
if (codecHandler == null) {
|
|
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`))
|
|
578
|
-
}
|
|
579
|
-
this.log.trace('calling handler "%s"', codecHandler.name)
|
|
580
|
-
|
|
581
|
-
response = await codecHandler.call(this, handlerArgs)
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
response.headers.set('etag', getETag({ cid, reqFormat, weak: false }))
|
|
351
|
+
const context: PluginContext = { cid, path, resource: resource.toString(), accept, query, options, withServerTiming, onProgress: options?.onProgress, modified: 0 }
|
|
585
352
|
|
|
586
|
-
|
|
587
|
-
response.headers.set('X-Ipfs-Path', ipfsPath)
|
|
353
|
+
this.log.trace('finding handler for cid code "%s" and response content type "%s"', cid.code, responseContentType)
|
|
588
354
|
|
|
589
|
-
|
|
590
|
-
let contentDisposition: string | undefined
|
|
591
|
-
|
|
592
|
-
// force download if requested
|
|
593
|
-
if (query.download === true) {
|
|
594
|
-
contentDisposition = 'attachment'
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// override filename if requested
|
|
598
|
-
if (query.filename != null) {
|
|
599
|
-
if (contentDisposition == null) {
|
|
600
|
-
contentDisposition = 'inline'
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
contentDisposition = `${contentDisposition}; ${getContentDispositionFilename(query.filename)}`
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
if (contentDisposition != null) {
|
|
607
|
-
response.headers.set('Content-Disposition', contentDisposition)
|
|
608
|
-
}
|
|
355
|
+
const response = await this.runPluginPipeline(context)
|
|
609
356
|
|
|
610
357
|
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid, path }))
|
|
611
358
|
|
|
612
|
-
return this.handleFinalResponse(response)
|
|
359
|
+
return this.handleFinalResponse(response ?? notSupportedResponse(resource.toString()), {
|
|
360
|
+
query: {
|
|
361
|
+
...query,
|
|
362
|
+
...context.query
|
|
363
|
+
},
|
|
364
|
+
cid,
|
|
365
|
+
reqFormat: context.reqFormat,
|
|
366
|
+
ttl,
|
|
367
|
+
protocol,
|
|
368
|
+
ipfsPath
|
|
369
|
+
})
|
|
613
370
|
}
|
|
614
371
|
|
|
615
372
|
/**
|