@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.
- package/README.md +200 -0
- package/dist/index.min.js +357 -35
- package/dist/src/index.d.ts +220 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +200 -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 -23
- 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/parse-resource.d.ts +2 -1
- package/dist/src/utils/parse-resource.d.ts.map +1 -1
- package/dist/src/utils/parse-resource.js +4 -3
- package/dist/src/utils/parse-resource.js.map +1 -1
- package/dist/src/utils/parse-url-string.d.ts +8 -3
- package/dist/src/utils/parse-url-string.d.ts.map +1 -1
- package/dist/src/utils/parse-url-string.js +30 -4
- package/dist/src/utils/parse-url-string.js.map +1 -1
- package/dist/src/utils/server-timing.d.ts +13 -0
- package/dist/src/utils/server-timing.d.ts.map +1 -0
- package/dist/src/utils/server-timing.js +19 -0
- package/dist/src/utils/server-timing.js.map +1 -0
- 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 +11 -20
- package/dist/src/verified-fetch.d.ts.map +1 -1
- package/dist/src/verified-fetch.js +174 -367
- 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 +223 -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 -29
- package/src/utils/dir-index-html.ts +445 -0
- package/src/utils/get-e-tag.ts +20 -3
- package/src/utils/parse-resource.ts +5 -4
- package/src/utils/parse-url-string.ts +38 -7
- package/src/utils/server-timing.ts +37 -0
- package/src/utils/walk-path.ts +3 -3
- package/src/verified-fetch.ts +198 -403
|
@@ -1,39 +1,28 @@
|
|
|
1
|
-
import { car } from '@helia/car';
|
|
2
1
|
import { ipns as heliaIpns } from '@helia/ipns';
|
|
3
|
-
import * as ipldDagCbor from '@ipld/dag-cbor';
|
|
4
|
-
import * as ipldDagJson from '@ipld/dag-json';
|
|
5
|
-
import { code as dagPbCode } from '@ipld/dag-pb';
|
|
6
2
|
import {} from '@libp2p/interface';
|
|
7
|
-
import {
|
|
8
|
-
import { Key } from 'interface-datastore';
|
|
9
|
-
import { exporter } from 'ipfs-unixfs-exporter';
|
|
10
|
-
import toBrowserReadableStream from 'it-to-browser-readablestream';
|
|
3
|
+
import { prefixLogger } from '@libp2p/logger';
|
|
11
4
|
import { LRUCache } from 'lru-cache';
|
|
12
5
|
import {} 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 {} 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
|
-
import {
|
|
36
|
-
import { handlePathWalking, isObjectNode } from './utils/walk-path.js';
|
|
25
|
+
import { serverTiming } from './utils/server-timing.js';
|
|
37
26
|
const SESSION_CACHE_MAX_SIZE = 100;
|
|
38
27
|
const SESSION_CACHE_TTL_MS = 60 * 1000;
|
|
39
28
|
function convertOptions(options) {
|
|
@@ -52,43 +41,15 @@ function convertOptions(options) {
|
|
|
52
41
|
signal
|
|
53
42
|
};
|
|
54
43
|
}
|
|
55
|
-
/**
|
|
56
|
-
* These are Accept header values that will cause content type sniffing to be
|
|
57
|
-
* skipped and set to these values.
|
|
58
|
-
*/
|
|
59
|
-
const RAW_HEADERS = [
|
|
60
|
-
'application/vnd.ipld.dag-json',
|
|
61
|
-
'application/vnd.ipld.raw',
|
|
62
|
-
'application/octet-stream'
|
|
63
|
-
];
|
|
64
|
-
/**
|
|
65
|
-
* if the user has specified an `Accept` header, and it's in our list of
|
|
66
|
-
* allowable "raw" format headers, use that instead of detecting the content
|
|
67
|
-
* type. This avoids the user from receiving something different when they
|
|
68
|
-
* signal that they want to `Accept` a specific mime type.
|
|
69
|
-
*/
|
|
70
|
-
function getOverridenRawContentType({ headers, accept }) {
|
|
71
|
-
// accept has already been resolved by getResolvedAcceptHeader, if we have it, use it.
|
|
72
|
-
const acceptHeader = accept ?? new Headers(headers).get('accept') ?? '';
|
|
73
|
-
// e.g. "Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8"
|
|
74
|
-
const acceptHeaders = acceptHeader.split(',')
|
|
75
|
-
.map(s => s.split(';')[0])
|
|
76
|
-
.map(s => s.trim());
|
|
77
|
-
for (const mimeType of acceptHeaders) {
|
|
78
|
-
if (mimeType === '*/*') {
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
if (RAW_HEADERS.includes(mimeType ?? '')) {
|
|
82
|
-
return mimeType;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
44
|
export class VerifiedFetch {
|
|
87
45
|
helia;
|
|
88
46
|
ipns;
|
|
89
47
|
log;
|
|
90
48
|
contentTypeParser;
|
|
91
49
|
blockstoreSessions;
|
|
50
|
+
serverTimingHeaders = [];
|
|
51
|
+
withServerTiming;
|
|
52
|
+
plugins = [];
|
|
92
53
|
constructor({ helia, ipns }, init) {
|
|
93
54
|
this.helia = helia;
|
|
94
55
|
this.log = helia.logger.forComponent('helia:verified-fetch');
|
|
@@ -101,9 +62,40 @@ export class VerifiedFetch {
|
|
|
101
62
|
store.close();
|
|
102
63
|
}
|
|
103
64
|
});
|
|
65
|
+
this.withServerTiming = init?.withServerTiming ?? false;
|
|
66
|
+
const pluginOptions = {
|
|
67
|
+
...init,
|
|
68
|
+
logger: prefixLogger('helia:verified-fetch'),
|
|
69
|
+
getBlockstore: (cid, resource, useSession, options) => this.getBlockstore(cid, resource, useSession, options),
|
|
70
|
+
handleServerTiming: async (name, description, fn) => this.handleServerTiming(name, description, fn, this.withServerTiming),
|
|
71
|
+
helia,
|
|
72
|
+
contentTypeParser: this.contentTypeParser
|
|
73
|
+
};
|
|
74
|
+
const defaultPlugins = [
|
|
75
|
+
new DagWalkPlugin(pluginOptions),
|
|
76
|
+
new IpnsRecordPlugin(pluginOptions),
|
|
77
|
+
new CarPlugin(pluginOptions),
|
|
78
|
+
new RawPlugin(pluginOptions),
|
|
79
|
+
new TarPlugin(pluginOptions),
|
|
80
|
+
new JsonPlugin(pluginOptions),
|
|
81
|
+
new DagCborPlugin(pluginOptions),
|
|
82
|
+
new DagPbPlugin(pluginOptions)
|
|
83
|
+
];
|
|
84
|
+
const customPlugins = init?.plugins?.map((pluginFactory) => pluginFactory(pluginOptions)) ?? [];
|
|
85
|
+
if (customPlugins.length > 0) {
|
|
86
|
+
// allow custom plugins to replace default plugins
|
|
87
|
+
const defaultPluginMap = new Map(defaultPlugins.map(plugin => [plugin.constructor.name, plugin]));
|
|
88
|
+
const customPluginMap = new Map(customPlugins.map(plugin => [plugin.constructor.name, plugin]));
|
|
89
|
+
this.plugins = defaultPlugins.map(plugin => customPluginMap.get(plugin.constructor.name) ?? plugin);
|
|
90
|
+
// Add any remaining custom plugins that don't replace a default plugin
|
|
91
|
+
this.plugins.push(...customPlugins.filter(plugin => !defaultPluginMap.has(plugin.constructor.name)));
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
this.plugins = defaultPlugins;
|
|
95
|
+
}
|
|
104
96
|
this.log.trace('created VerifiedFetch instance');
|
|
105
97
|
}
|
|
106
|
-
getBlockstore(root, resource, useSession, options) {
|
|
98
|
+
getBlockstore(root, resource, useSession = true, options = {}) {
|
|
107
99
|
const key = resourceToSessionCacheKey(resource);
|
|
108
100
|
if (!useSession) {
|
|
109
101
|
return this.helia.blockstore;
|
|
@@ -115,281 +107,138 @@ export class VerifiedFetch {
|
|
|
115
107
|
}
|
|
116
108
|
return session;
|
|
117
109
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
*/
|
|
122
|
-
async handleIPNSRecord({ resource, cid, path, options }) {
|
|
123
|
-
if (path !== '' || !(resource.startsWith('ipns://') || resource.includes('.ipns.'))) {
|
|
124
|
-
this.log.error('invalid request for IPNS name "%s" and path "%s"', resource, path);
|
|
125
|
-
return badRequestResponse(resource, 'Invalid IPNS name');
|
|
110
|
+
async handleServerTiming(name, description, fn, withServerTiming) {
|
|
111
|
+
if (!withServerTiming) {
|
|
112
|
+
return fn();
|
|
126
113
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
this.log.trace('trying to parse peer id from "%s"', peerIdString);
|
|
132
|
-
peerId = getPeerIdFromString(peerIdString);
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
135
|
-
const peerIdString = resource.split('.ipns.')[0].split('://')[1];
|
|
136
|
-
this.log.trace('trying to parse peer id from "%s"', peerIdString);
|
|
137
|
-
peerId = getPeerIdFromString(peerIdString);
|
|
138
|
-
}
|
|
114
|
+
const { error, result, header } = await serverTiming(name, description, fn);
|
|
115
|
+
this.serverTimingHeaders.push(header);
|
|
116
|
+
if (error != null) {
|
|
117
|
+
throw error;
|
|
139
118
|
}
|
|
140
|
-
|
|
141
|
-
this.log.error('could not parse peer id from IPNS url %s', resource, err);
|
|
142
|
-
return badRequestResponse(resource, err);
|
|
143
|
-
}
|
|
144
|
-
// since this call happens after parseResource, we've already resolved the
|
|
145
|
-
// IPNS name so a local copy should be in the helia datastore, so we can
|
|
146
|
-
// just read it out..
|
|
147
|
-
const routingKey = uint8ArrayConcat([
|
|
148
|
-
uint8ArrayFromString('/ipns/'),
|
|
149
|
-
peerId.toMultihash().bytes
|
|
150
|
-
]);
|
|
151
|
-
const datastoreKey = new Key('/dht/record/' + uint8ArrayToString(routingKey, 'base32'), false);
|
|
152
|
-
const buf = await this.helia.datastore.get(datastoreKey, options);
|
|
153
|
-
const record = DHTRecord.deserialize(buf);
|
|
154
|
-
const response = okResponse(resource, record.value);
|
|
155
|
-
response.headers.set('content-type', 'application/vnd.ipfs.ipns-record');
|
|
156
|
-
return response;
|
|
119
|
+
return result;
|
|
157
120
|
}
|
|
158
121
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
122
|
+
* The last place a Response touches in verified-fetch before being returned to the user. This is where we add the
|
|
123
|
+
* Server-Timing header to the response if it has been collected. It should be used for any final processing of the
|
|
124
|
+
* response before it is returned to the user.
|
|
161
125
|
*/
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
response.headers.set('content-type', 'application/vnd.ipld.car; version=1');
|
|
168
|
-
return response;
|
|
169
|
-
}
|
|
170
|
-
/**
|
|
171
|
-
* Accepts a UnixFS `CID` and returns a `.tar` file containing the file or
|
|
172
|
-
* directory structure referenced by the `CID`.
|
|
173
|
-
*/
|
|
174
|
-
async handleTar({ resource, cid, path, session, options }) {
|
|
175
|
-
if (cid.code !== dagPbCode && cid.code !== rawCode) {
|
|
176
|
-
return notAcceptableResponse('only UnixFS data can be returned in a TAR file');
|
|
126
|
+
handleFinalResponse(response, { query, cid, reqFormat, ttl, protocol, ipfsPath } = {}) {
|
|
127
|
+
if (this.serverTimingHeaders.length > 0) {
|
|
128
|
+
const headerString = this.serverTimingHeaders.join(', ');
|
|
129
|
+
response.headers.set('Server-Timing', headerString);
|
|
130
|
+
this.serverTimingHeaders = [];
|
|
177
131
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
async handleJson({ resource, cid, path, accept, session, options }) {
|
|
185
|
-
this.log.trace('fetching %c/%s', cid, path);
|
|
186
|
-
const blockstore = this.getBlockstore(cid, resource, session, options);
|
|
187
|
-
const block = await blockstore.get(cid, options);
|
|
188
|
-
let body;
|
|
189
|
-
if (accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
|
|
190
|
-
try {
|
|
191
|
-
// if vnd.ipld.dag-cbor has been specified, convert to the format - note
|
|
192
|
-
// that this supports more data types than regular JSON, the content-type
|
|
193
|
-
// response header is set so the user knows to process it differently
|
|
194
|
-
const obj = ipldDagJson.decode(block);
|
|
195
|
-
body = ipldDagCbor.encode(obj);
|
|
196
|
-
}
|
|
197
|
-
catch (err) {
|
|
198
|
-
this.log.error('could not transform %c to application/vnd.ipld.dag-cbor', err);
|
|
199
|
-
return notAcceptableResponse(resource);
|
|
200
|
-
}
|
|
132
|
+
// set Content-Disposition header
|
|
133
|
+
let contentDisposition;
|
|
134
|
+
this.log.trace('checking for content disposition');
|
|
135
|
+
// force download if requested
|
|
136
|
+
if (query?.download === true) {
|
|
137
|
+
contentDisposition = 'attachment';
|
|
201
138
|
}
|
|
202
139
|
else {
|
|
203
|
-
|
|
204
|
-
body = block;
|
|
140
|
+
this.log.trace('download not requested');
|
|
205
141
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
let terminalElement;
|
|
213
|
-
const blockstore = this.getBlockstore(cid, resource, session, options);
|
|
214
|
-
// need to walk path, if it exists, to get the terminal element
|
|
215
|
-
const pathDetails = await handlePathWalking({ cid, path, resource, options, blockstore, log: this.log });
|
|
216
|
-
if (pathDetails instanceof Response) {
|
|
217
|
-
return pathDetails;
|
|
218
|
-
}
|
|
219
|
-
const ipfsRoots = pathDetails.ipfsRoots;
|
|
220
|
-
if (isObjectNode(pathDetails.terminalElement)) {
|
|
221
|
-
terminalElement = pathDetails.terminalElement;
|
|
142
|
+
// override filename if requested
|
|
143
|
+
if (query?.filename != null) {
|
|
144
|
+
if (contentDisposition == null) {
|
|
145
|
+
contentDisposition = 'inline';
|
|
146
|
+
}
|
|
147
|
+
contentDisposition = `${contentDisposition}; ${getContentDispositionFilename(query.filename)}`;
|
|
222
148
|
}
|
|
223
149
|
else {
|
|
224
|
-
|
|
225
|
-
this.log.error('terminal element is not a dag-cbor node');
|
|
226
|
-
return notSupportedResponse(resource, 'Terminal element is not a dag-cbor node');
|
|
227
|
-
}
|
|
228
|
-
const block = terminalElement.node;
|
|
229
|
-
let body;
|
|
230
|
-
if (accept === 'application/octet-stream' || accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
|
|
231
|
-
// skip decoding
|
|
232
|
-
body = block;
|
|
150
|
+
this.log.trace('no filename specified in query');
|
|
233
151
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
// if vnd.ipld.dag-json 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 = ipldDagCbor.decode(block);
|
|
240
|
-
body = ipldDagJson.encode(obj);
|
|
241
|
-
}
|
|
242
|
-
catch (err) {
|
|
243
|
-
this.log.error('could not transform %c to application/vnd.ipld.dag-json', err);
|
|
244
|
-
return notAcceptableResponse(resource);
|
|
245
|
-
}
|
|
152
|
+
if (contentDisposition != null) {
|
|
153
|
+
response.headers.set('Content-Disposition', contentDisposition);
|
|
246
154
|
}
|
|
247
155
|
else {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
this.log('could not decode DAG-CBOR as JSON-safe, falling back to `application/octet-stream`', err);
|
|
257
|
-
body = block;
|
|
258
|
-
}
|
|
156
|
+
this.log.trace('no content disposition specified');
|
|
157
|
+
}
|
|
158
|
+
if (cid != null && response.headers.get('etag') == null) {
|
|
159
|
+
response.headers.set('etag', getETag({ cid, reqFormat, weak: false }));
|
|
160
|
+
}
|
|
161
|
+
if (protocol != null) {
|
|
162
|
+
setCacheControlHeader({ response, ttl, protocol });
|
|
259
163
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
accept = body instanceof Uint8Array ? 'application/octet-stream' : 'application/json';
|
|
164
|
+
if (ipfsPath != null) {
|
|
165
|
+
response.headers.set('X-Ipfs-Path', ipfsPath);
|
|
263
166
|
}
|
|
264
|
-
response.headers.set('content-type', accept);
|
|
265
|
-
setIpfsRoots(response, ipfsRoots);
|
|
266
167
|
return response;
|
|
267
168
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Runs plugins in a loop. After each plugin that returns `null` (partial/no final),
|
|
171
|
+
* we re-check `canHandle()` for all plugins in the next iteration if the context changed.
|
|
172
|
+
*/
|
|
173
|
+
async runPluginPipeline(context, maxPasses = 3) {
|
|
174
|
+
let finalResponse;
|
|
175
|
+
let passCount = 0;
|
|
176
|
+
const pluginsUsed = new Set();
|
|
177
|
+
let prevModificationId = context.modified;
|
|
178
|
+
while (passCount < maxPasses) {
|
|
179
|
+
this.log(`Starting pipeline pass #${passCount + 1}`);
|
|
180
|
+
passCount++;
|
|
181
|
+
// gather plugins that say they can handle the *current* context, but haven't been used yet
|
|
182
|
+
const readyPlugins = this.plugins.filter(p => !pluginsUsed.has(p.constructor.name)).filter(p => p.canHandle(context));
|
|
183
|
+
if (readyPlugins.length === 0) {
|
|
184
|
+
this.log.trace('No plugins can handle the current context.. checking by CID code');
|
|
185
|
+
const plugins = this.plugins.filter(p => p.codes.includes(context.cid.code));
|
|
186
|
+
if (plugins.length > 0) {
|
|
187
|
+
readyPlugins.push(...plugins);
|
|
287
188
|
}
|
|
288
|
-
else
|
|
289
|
-
this.log('
|
|
290
|
-
|
|
189
|
+
else {
|
|
190
|
+
this.log.trace('No plugins found that can handle request by CID code; exiting pipeline.');
|
|
191
|
+
break;
|
|
291
192
|
}
|
|
292
|
-
// fall-through simulates following the redirect?
|
|
293
|
-
resource = `${resource}/`;
|
|
294
|
-
redirected = true;
|
|
295
193
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
194
|
+
this.log.trace('Plugins ready to handle request: ', readyPlugins.map(p => p.constructor.name).join(', '));
|
|
195
|
+
// track if any plugin changed the context or returned a response
|
|
196
|
+
let contextChanged = false;
|
|
197
|
+
let pluginHandled = false;
|
|
198
|
+
for (const plugin of readyPlugins) {
|
|
199
|
+
try {
|
|
200
|
+
this.log.trace('Invoking plugin:', plugin.constructor.name);
|
|
201
|
+
pluginsUsed.add(plugin.constructor.name);
|
|
202
|
+
const maybeResponse = await plugin.handle(context);
|
|
203
|
+
if (maybeResponse != null) {
|
|
204
|
+
// if a plugin returns a final Response, short-circuit
|
|
205
|
+
finalResponse = maybeResponse;
|
|
206
|
+
pluginHandled = true;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
context.options?.signal?.throwIfAborted();
|
|
212
|
+
this.log.error('Error in plugin:', plugin.constructor.name, err);
|
|
213
|
+
// if fatal, short-circuit the pipeline
|
|
214
|
+
if (err.name === 'PluginFatalError') {
|
|
215
|
+
// if plugin provides a custom error response, return it
|
|
216
|
+
return err.response ?? badGatewayResponse(context.resource, 'Failed to fetch');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
// on each plugin call, check for changes in the context
|
|
221
|
+
const newModificationId = context.modified;
|
|
222
|
+
contextChanged = newModificationId !== prevModificationId;
|
|
223
|
+
if (contextChanged) {
|
|
224
|
+
prevModificationId = newModificationId;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (finalResponse != null) {
|
|
228
|
+
this.log.trace('Plugin produced final response:', plugin.constructor.name);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
311
231
|
}
|
|
312
|
-
|
|
313
|
-
|
|
232
|
+
if (pluginHandled && finalResponse != null) {
|
|
233
|
+
break;
|
|
314
234
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
byteRangeContext.setFileSize(terminalElement.unixfs.fileSize());
|
|
319
|
-
this.log.trace('fileSize for rangeRequest %d', byteRangeContext.getFileSize());
|
|
320
|
-
}
|
|
321
|
-
const offset = byteRangeContext.offset;
|
|
322
|
-
const length = byteRangeContext.length;
|
|
323
|
-
this.log.trace('calling exporter for %c/%s with offset=%o & length=%o', resolvedCID, path, offset, length);
|
|
324
|
-
try {
|
|
325
|
-
const entry = await exporter(resolvedCID, this.helia.blockstore, {
|
|
326
|
-
signal: options?.signal,
|
|
327
|
-
onProgress: options?.onProgress
|
|
328
|
-
});
|
|
329
|
-
const asyncIter = entry.content({
|
|
330
|
-
signal: options?.signal,
|
|
331
|
-
onProgress: options?.onProgress,
|
|
332
|
-
offset,
|
|
333
|
-
length
|
|
334
|
-
});
|
|
335
|
-
this.log('got async iterator for %c/%s', cid, path);
|
|
336
|
-
const { stream, firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
|
|
337
|
-
onProgress: options?.onProgress,
|
|
338
|
-
signal: options?.signal
|
|
339
|
-
});
|
|
340
|
-
byteRangeContext.setBody(stream);
|
|
341
|
-
// if not a valid range request, okRangeRequest will call okResponse
|
|
342
|
-
const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log: this.log }, {
|
|
343
|
-
redirected
|
|
344
|
-
});
|
|
345
|
-
await setContentType({ bytes: firstChunk, path, response, contentTypeParser: this.contentTypeParser, log: this.log });
|
|
346
|
-
setIpfsRoots(response, ipfsRoots);
|
|
347
|
-
return response;
|
|
348
|
-
}
|
|
349
|
-
catch (err) {
|
|
350
|
-
options?.signal?.throwIfAborted();
|
|
351
|
-
this.log.error('error streaming %c/%s', cid, path, err);
|
|
352
|
-
if (byteRangeContext.isRangeRequest && err.code === 'ERR_INVALID_PARAMS') {
|
|
353
|
-
return badRangeResponse(resource);
|
|
235
|
+
if (!contextChanged) {
|
|
236
|
+
this.log.trace('No context changes and no final response; exiting pipeline.');
|
|
237
|
+
break;
|
|
354
238
|
}
|
|
355
|
-
return badGatewayResponse(resource.toString(), 'Unable to stream content');
|
|
356
239
|
}
|
|
357
|
-
|
|
358
|
-
async handleRaw({ resource, cid, path, session, options, accept }) {
|
|
359
|
-
/**
|
|
360
|
-
* if we have a path, we can't walk it, so we need to return a 404.
|
|
361
|
-
*
|
|
362
|
-
* @see https://github.com/ipfs/gateway-conformance/blob/26994cfb056b717a23bf694ce4e94386728748dd/tests/subdomain_gateway_ipfs_test.go#L198-L204
|
|
363
|
-
*/
|
|
364
|
-
if (path !== '') {
|
|
365
|
-
this.log.trace('404-ing raw codec request for %c/%s', cid, path);
|
|
366
|
-
return notFoundResponse(resource, 'Raw codec does not support paths');
|
|
367
|
-
}
|
|
368
|
-
const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers);
|
|
369
|
-
const blockstore = this.getBlockstore(cid, resource, session, options);
|
|
370
|
-
const result = await blockstore.get(cid, options);
|
|
371
|
-
byteRangeContext.setBody(result);
|
|
372
|
-
const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log: this.log }, {
|
|
373
|
-
redirected: false
|
|
374
|
-
});
|
|
375
|
-
// if the user has specified an `Accept` header that corresponds to a raw
|
|
376
|
-
// type, honour that header, so for example they don't request
|
|
377
|
-
// `application/vnd.ipld.raw` but get `application/octet-stream`
|
|
378
|
-
await setContentType({ bytes: result, path, response, defaultContentType: getOverridenRawContentType({ headers: options?.headers, accept }), contentTypeParser: this.contentTypeParser, log: this.log });
|
|
379
|
-
return response;
|
|
240
|
+
return finalResponse;
|
|
380
241
|
}
|
|
381
|
-
/**
|
|
382
|
-
* If the user has not specified an Accept header or format query string arg,
|
|
383
|
-
* use the CID codec to choose an appropriate handler for the block data.
|
|
384
|
-
*/
|
|
385
|
-
codecHandlers = {
|
|
386
|
-
[dagPbCode]: this.handleDagPb,
|
|
387
|
-
[ipldDagJson.code]: this.handleJson,
|
|
388
|
-
[jsonCode]: this.handleJson,
|
|
389
|
-
[ipldDagCbor.code]: this.handleDagCbor,
|
|
390
|
-
[rawCode]: this.handleRaw,
|
|
391
|
-
[identity.code]: this.handleRaw
|
|
392
|
-
};
|
|
393
242
|
/**
|
|
394
243
|
* We're starting to get to the point where we need a queue or pipeline of
|
|
395
244
|
* operations to perform and a single place to handle errors.
|
|
@@ -400,6 +249,7 @@ export class VerifiedFetch {
|
|
|
400
249
|
async fetch(resource, opts) {
|
|
401
250
|
this.log('fetch %s', resource);
|
|
402
251
|
const options = convertOptions(opts);
|
|
252
|
+
const withServerTiming = options?.withServerTiming ?? this.withServerTiming;
|
|
403
253
|
options?.onProgress?.(new CustomProgressEvent('verified-fetch:request:start', { resource }));
|
|
404
254
|
// resolve the CID/path from the requested resource
|
|
405
255
|
let cid;
|
|
@@ -409,90 +259,47 @@ export class VerifiedFetch {
|
|
|
409
259
|
let protocol;
|
|
410
260
|
let ipfsPath;
|
|
411
261
|
try {
|
|
412
|
-
const result = await parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, options);
|
|
262
|
+
const result = await this.handleServerTiming('parse-resource', '', async () => parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, { withServerTiming, ...options }), withServerTiming);
|
|
413
263
|
cid = result.cid;
|
|
414
264
|
path = result.path;
|
|
415
265
|
query = result.query;
|
|
416
266
|
ttl = result.ttl;
|
|
417
267
|
protocol = result.protocol;
|
|
418
268
|
ipfsPath = result.ipfsPath;
|
|
269
|
+
this.serverTimingHeaders.push(...result.serverTimings.map(({ header }) => header));
|
|
419
270
|
}
|
|
420
271
|
catch (err) {
|
|
421
272
|
options?.signal?.throwIfAborted();
|
|
422
273
|
this.log.error('error parsing resource %s', resource, err);
|
|
423
|
-
return badRequestResponse(resource.toString(), err);
|
|
274
|
+
return this.handleFinalResponse(badRequestResponse(resource.toString(), err));
|
|
424
275
|
}
|
|
425
276
|
options?.onProgress?.(new CustomProgressEvent('verified-fetch:request:resolve', { cid, path }));
|
|
426
277
|
const acceptHeader = getResolvedAcceptHeader({ query, headers: options?.headers, logger: this.helia.logger });
|
|
427
278
|
const accept = selectOutputType(cid, acceptHeader);
|
|
428
279
|
this.log('output type %s', accept);
|
|
429
280
|
if (acceptHeader != null && accept == null) {
|
|
430
|
-
return notAcceptableResponse(resource.toString());
|
|
281
|
+
return this.handleFinalResponse(notAcceptableResponse(resource.toString()));
|
|
431
282
|
}
|
|
432
|
-
|
|
433
|
-
let reqFormat;
|
|
283
|
+
const responseContentType = accept?.split(';')[0] ?? 'application/octet-stream';
|
|
434
284
|
const redirectResponse = await getRedirectResponse({ resource, options, logger: this.helia.logger, cid });
|
|
435
285
|
if (redirectResponse != null) {
|
|
436
|
-
return redirectResponse;
|
|
437
|
-
}
|
|
438
|
-
const handlerArgs = { resource: resource.toString(), cid, path, accept, session: options?.session ?? true, options };
|
|
439
|
-
if (accept === 'application/vnd.ipfs.ipns-record') {
|
|
440
|
-
// the user requested a raw IPNS record
|
|
441
|
-
reqFormat = 'ipns-record';
|
|
442
|
-
response = await this.handleIPNSRecord(handlerArgs);
|
|
443
|
-
}
|
|
444
|
-
else if (accept === 'application/vnd.ipld.car') {
|
|
445
|
-
// the user requested a CAR file
|
|
446
|
-
reqFormat = 'car';
|
|
447
|
-
query.download = true;
|
|
448
|
-
query.filename = query.filename ?? `${cid.toString()}.car`;
|
|
449
|
-
response = await this.handleCar(handlerArgs);
|
|
450
|
-
}
|
|
451
|
-
else if (accept === 'application/vnd.ipld.raw') {
|
|
452
|
-
// the user requested a raw block
|
|
453
|
-
reqFormat = 'raw';
|
|
454
|
-
query.download = true;
|
|
455
|
-
query.filename = query.filename ?? `${cid.toString()}.bin`;
|
|
456
|
-
response = await this.handleRaw(handlerArgs);
|
|
457
|
-
}
|
|
458
|
-
else if (accept === 'application/x-tar') {
|
|
459
|
-
// the user requested a TAR file
|
|
460
|
-
reqFormat = 'tar';
|
|
461
|
-
query.download = true;
|
|
462
|
-
query.filename = query.filename ?? `${cid.toString()}.tar`;
|
|
463
|
-
response = await this.handleTar(handlerArgs);
|
|
464
|
-
}
|
|
465
|
-
else {
|
|
466
|
-
this.log.trace('finding handler for cid code "%s" and output type "%s"', cid.code, accept);
|
|
467
|
-
// derive the handler from the CID type
|
|
468
|
-
const codecHandler = this.codecHandlers[cid.code];
|
|
469
|
-
if (codecHandler == null) {
|
|
470
|
-
return notSupportedResponse(`Support for codec with code ${cid.code} is not yet implemented. Please open an issue at https://github.com/ipfs/helia-verified-fetch/issues/new`);
|
|
471
|
-
}
|
|
472
|
-
this.log.trace('calling handler "%s"', codecHandler.name);
|
|
473
|
-
response = await codecHandler.call(this, handlerArgs);
|
|
474
|
-
}
|
|
475
|
-
response.headers.set('etag', getETag({ cid, reqFormat, weak: false }));
|
|
476
|
-
setCacheControlHeader({ response, ttl, protocol });
|
|
477
|
-
response.headers.set('X-Ipfs-Path', ipfsPath);
|
|
478
|
-
// set Content-Disposition header
|
|
479
|
-
let contentDisposition;
|
|
480
|
-
// force download if requested
|
|
481
|
-
if (query.download === true) {
|
|
482
|
-
contentDisposition = 'attachment';
|
|
483
|
-
}
|
|
484
|
-
// override filename if requested
|
|
485
|
-
if (query.filename != null) {
|
|
486
|
-
if (contentDisposition == null) {
|
|
487
|
-
contentDisposition = 'inline';
|
|
488
|
-
}
|
|
489
|
-
contentDisposition = `${contentDisposition}; ${getContentDispositionFilename(query.filename)}`;
|
|
490
|
-
}
|
|
491
|
-
if (contentDisposition != null) {
|
|
492
|
-
response.headers.set('Content-Disposition', contentDisposition);
|
|
286
|
+
return this.handleFinalResponse(redirectResponse);
|
|
493
287
|
}
|
|
288
|
+
const context = { cid, path, resource: resource.toString(), accept, query, options, withServerTiming, onProgress: options?.onProgress, modified: 0 };
|
|
289
|
+
this.log.trace('finding handler for cid code "%s" and response content type "%s"', cid.code, responseContentType);
|
|
290
|
+
const response = await this.runPluginPipeline(context);
|
|
494
291
|
options?.onProgress?.(new CustomProgressEvent('verified-fetch:request:end', { cid, path }));
|
|
495
|
-
return response
|
|
292
|
+
return this.handleFinalResponse(response ?? notSupportedResponse(resource.toString()), {
|
|
293
|
+
query: {
|
|
294
|
+
...query,
|
|
295
|
+
...context.query
|
|
296
|
+
},
|
|
297
|
+
cid,
|
|
298
|
+
reqFormat: context.reqFormat,
|
|
299
|
+
ttl,
|
|
300
|
+
protocol,
|
|
301
|
+
ipfsPath
|
|
302
|
+
});
|
|
496
303
|
}
|
|
497
304
|
/**
|
|
498
305
|
* Start the Helia instance
|