@helia/verified-fetch 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -0
- package/dist/index.min.js +354 -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 +42 -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 +384 -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/response-headers.d.ts.map +1 -1
- package/dist/src/utils/response-headers.js +4 -0
- package/dist/src/utils/response-headers.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 +50 -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 +442 -0
- package/src/utils/get-e-tag.ts +20 -3
- package/src/utils/response-headers.ts +4 -0
- package/src/utils/walk-path.ts +3 -3
- package/src/verified-fetch.ts +187 -430
|
@@ -1,40 +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
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
|
const SESSION_CACHE_MAX_SIZE = 100;
|
|
39
27
|
const SESSION_CACHE_TTL_MS = 60 * 1000;
|
|
40
28
|
function convertOptions(options) {
|
|
@@ -53,37 +41,6 @@ function convertOptions(options) {
|
|
|
53
41
|
signal
|
|
54
42
|
};
|
|
55
43
|
}
|
|
56
|
-
/**
|
|
57
|
-
* These are Accept header values that will cause content type sniffing to be
|
|
58
|
-
* skipped and set to these values.
|
|
59
|
-
*/
|
|
60
|
-
const RAW_HEADERS = [
|
|
61
|
-
'application/vnd.ipld.dag-json',
|
|
62
|
-
'application/vnd.ipld.raw',
|
|
63
|
-
'application/octet-stream'
|
|
64
|
-
];
|
|
65
|
-
/**
|
|
66
|
-
* if the user has specified an `Accept` header, and it's in our list of
|
|
67
|
-
* allowable "raw" format headers, use that instead of detecting the content
|
|
68
|
-
* type. This avoids the user from receiving something different when they
|
|
69
|
-
* signal that they want to `Accept` a specific mime type.
|
|
70
|
-
*/
|
|
71
|
-
function getOverridenRawContentType({ headers, accept }) {
|
|
72
|
-
// accept has already been resolved by getResolvedAcceptHeader, if we have it, use it.
|
|
73
|
-
const acceptHeader = accept ?? new Headers(headers).get('accept') ?? '';
|
|
74
|
-
// e.g. "Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8"
|
|
75
|
-
const acceptHeaders = acceptHeader.split(',')
|
|
76
|
-
.map(s => s.split(';')[0])
|
|
77
|
-
.map(s => s.trim());
|
|
78
|
-
for (const mimeType of acceptHeaders) {
|
|
79
|
-
if (mimeType === '*/*') {
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
if (RAW_HEADERS.includes(mimeType ?? '')) {
|
|
83
|
-
return mimeType;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
44
|
export class VerifiedFetch {
|
|
88
45
|
helia;
|
|
89
46
|
ipns;
|
|
@@ -92,6 +49,7 @@ export class VerifiedFetch {
|
|
|
92
49
|
blockstoreSessions;
|
|
93
50
|
serverTimingHeaders = [];
|
|
94
51
|
withServerTiming;
|
|
52
|
+
plugins = [];
|
|
95
53
|
constructor({ helia, ipns }, init) {
|
|
96
54
|
this.helia = helia;
|
|
97
55
|
this.log = helia.logger.forComponent('helia:verified-fetch');
|
|
@@ -105,9 +63,39 @@ export class VerifiedFetch {
|
|
|
105
63
|
}
|
|
106
64
|
});
|
|
107
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
|
+
}
|
|
108
96
|
this.log.trace('created VerifiedFetch instance');
|
|
109
97
|
}
|
|
110
|
-
getBlockstore(root, resource, useSession, options) {
|
|
98
|
+
getBlockstore(root, resource, useSession = true, options = {}) {
|
|
111
99
|
const key = resourceToSessionCacheKey(resource);
|
|
112
100
|
if (!useSession) {
|
|
113
101
|
return this.helia.blockstore;
|
|
@@ -119,304 +107,137 @@ export class VerifiedFetch {
|
|
|
119
107
|
}
|
|
120
108
|
return session;
|
|
121
109
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
*/
|
|
126
|
-
async handleIPNSRecord({ resource, cid, path, options }) {
|
|
127
|
-
if (path !== '' || !(resource.startsWith('ipns://') || resource.includes('.ipns.'))) {
|
|
128
|
-
this.log.error('invalid request for IPNS name "%s" and path "%s"', resource, path);
|
|
129
|
-
return badRequestResponse(resource, 'Invalid IPNS name');
|
|
130
|
-
}
|
|
131
|
-
let peerId;
|
|
132
|
-
try {
|
|
133
|
-
if (resource.startsWith('ipns://')) {
|
|
134
|
-
const peerIdString = resource.replace('ipns://', '');
|
|
135
|
-
this.log.trace('trying to parse peer id from "%s"', peerIdString);
|
|
136
|
-
peerId = getPeerIdFromString(peerIdString);
|
|
137
|
-
}
|
|
138
|
-
else {
|
|
139
|
-
const peerIdString = resource.split('.ipns.')[0].split('://')[1];
|
|
140
|
-
this.log.trace('trying to parse peer id from "%s"', peerIdString);
|
|
141
|
-
peerId = getPeerIdFromString(peerIdString);
|
|
142
|
-
}
|
|
110
|
+
async handleServerTiming(name, description, fn, withServerTiming) {
|
|
111
|
+
if (!withServerTiming) {
|
|
112
|
+
return fn();
|
|
143
113
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
114
|
+
const { error, result, header } = await serverTiming(name, description, fn);
|
|
115
|
+
this.serverTimingHeaders.push(header);
|
|
116
|
+
if (error != null) {
|
|
117
|
+
throw error;
|
|
147
118
|
}
|
|
148
|
-
|
|
149
|
-
// IPNS name so a local copy should be in the helia datastore, so we can
|
|
150
|
-
// just read it out..
|
|
151
|
-
const routingKey = uint8ArrayConcat([
|
|
152
|
-
uint8ArrayFromString('/ipns/'),
|
|
153
|
-
peerId.toMultihash().bytes
|
|
154
|
-
]);
|
|
155
|
-
const datastoreKey = new Key('/dht/record/' + uint8ArrayToString(routingKey, 'base32'), false);
|
|
156
|
-
const buf = await this.helia.datastore.get(datastoreKey, options);
|
|
157
|
-
const record = DHTRecord.deserialize(buf);
|
|
158
|
-
const response = okResponse(resource, record.value);
|
|
159
|
-
response.headers.set('content-type', 'application/vnd.ipfs.ipns-record');
|
|
160
|
-
return response;
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* Accepts a `CID` and returns a `Response` with a body stream that is a CAR
|
|
164
|
-
* of the `DAG` referenced by the `CID`.
|
|
165
|
-
*/
|
|
166
|
-
async handleCar({ resource, cid, session, options }) {
|
|
167
|
-
const blockstore = this.getBlockstore(cid, resource, session, options);
|
|
168
|
-
const c = car({ blockstore, getCodec: this.helia.getCodec });
|
|
169
|
-
const stream = toBrowserReadableStream(c.stream(cid, options));
|
|
170
|
-
const response = okResponse(resource, stream);
|
|
171
|
-
response.headers.set('content-type', 'application/vnd.ipld.car; version=1');
|
|
172
|
-
return response;
|
|
119
|
+
return result;
|
|
173
120
|
}
|
|
174
121
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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.
|
|
177
125
|
*/
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
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 = [];
|
|
181
131
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
async handleJson({ resource, cid, path, accept, session, options }) {
|
|
189
|
-
this.log.trace('fetching %c/%s', cid, path);
|
|
190
|
-
const blockstore = this.getBlockstore(cid, resource, session, options);
|
|
191
|
-
const block = await blockstore.get(cid, options);
|
|
192
|
-
let body;
|
|
193
|
-
if (accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
|
|
194
|
-
try {
|
|
195
|
-
// if vnd.ipld.dag-cbor has been specified, convert to the format - note
|
|
196
|
-
// that this supports more data types than regular JSON, the content-type
|
|
197
|
-
// response header is set so the user knows to process it differently
|
|
198
|
-
const obj = ipldDagJson.decode(block);
|
|
199
|
-
body = ipldDagCbor.encode(obj);
|
|
200
|
-
}
|
|
201
|
-
catch (err) {
|
|
202
|
-
this.log.error('could not transform %c to application/vnd.ipld.dag-cbor', err);
|
|
203
|
-
return notAcceptableResponse(resource);
|
|
204
|
-
}
|
|
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';
|
|
205
138
|
}
|
|
206
139
|
else {
|
|
207
|
-
|
|
208
|
-
body = block;
|
|
209
|
-
}
|
|
210
|
-
const response = okResponse(resource, body);
|
|
211
|
-
response.headers.set('content-type', accept ?? 'application/json');
|
|
212
|
-
return response;
|
|
213
|
-
}
|
|
214
|
-
async handleDagCbor({ resource, cid, path, accept, session, options, withServerTiming }) {
|
|
215
|
-
this.log.trace('fetching %c/%s', cid, path);
|
|
216
|
-
let terminalElement;
|
|
217
|
-
const blockstore = this.getBlockstore(cid, resource, session, options);
|
|
218
|
-
// need to walk path, if it exists, to get the terminal element
|
|
219
|
-
const pathDetails = await this.handleServerTiming('path-walking', '', async () => handlePathWalking({ cid, path, resource, options, blockstore, log: this.log, withServerTiming }), withServerTiming);
|
|
220
|
-
if (pathDetails instanceof Response) {
|
|
221
|
-
return pathDetails;
|
|
140
|
+
this.log.trace('download not requested');
|
|
222
141
|
}
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
|
|
142
|
+
// override filename if requested
|
|
143
|
+
if (query?.filename != null) {
|
|
144
|
+
if (contentDisposition == null) {
|
|
145
|
+
contentDisposition = 'inline';
|
|
146
|
+
}
|
|
147
|
+
contentDisposition = `${contentDisposition}; ${getContentDispositionFilename(query.filename)}`;
|
|
226
148
|
}
|
|
227
149
|
else {
|
|
228
|
-
|
|
229
|
-
this.log.error('terminal element is not a dag-cbor node');
|
|
230
|
-
return notSupportedResponse(resource, 'Terminal element is not a dag-cbor node');
|
|
150
|
+
this.log.trace('no filename specified in query');
|
|
231
151
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (accept === 'application/octet-stream' || accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
|
|
235
|
-
// skip decoding
|
|
236
|
-
body = block;
|
|
237
|
-
}
|
|
238
|
-
else if (accept === 'application/vnd.ipld.dag-json') {
|
|
239
|
-
try {
|
|
240
|
-
// if vnd.ipld.dag-json has been specified, convert to the format - note
|
|
241
|
-
// that this supports more data types than regular JSON, the content-type
|
|
242
|
-
// response header is set so the user knows to process it differently
|
|
243
|
-
const obj = ipldDagCbor.decode(block);
|
|
244
|
-
body = ipldDagJson.encode(obj);
|
|
245
|
-
}
|
|
246
|
-
catch (err) {
|
|
247
|
-
this.log.error('could not transform %c to application/vnd.ipld.dag-json', err);
|
|
248
|
-
return notAcceptableResponse(resource);
|
|
249
|
-
}
|
|
152
|
+
if (contentDisposition != null) {
|
|
153
|
+
response.headers.set('Content-Disposition', contentDisposition);
|
|
250
154
|
}
|
|
251
155
|
else {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
this.log('could not decode DAG-CBOR as JSON-safe, falling back to `application/octet-stream`', err);
|
|
261
|
-
body = block;
|
|
262
|
-
}
|
|
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 });
|
|
263
163
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
accept = body instanceof Uint8Array ? 'application/octet-stream' : 'application/json';
|
|
164
|
+
if (ipfsPath != null) {
|
|
165
|
+
response.headers.set('X-Ipfs-Path', ipfsPath);
|
|
267
166
|
}
|
|
268
|
-
response.headers.set('content-type', accept);
|
|
269
|
-
setIpfsRoots(response, ipfsRoots);
|
|
270
167
|
return response;
|
|
271
168
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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);
|
|
291
188
|
}
|
|
292
|
-
else
|
|
293
|
-
this.log('
|
|
294
|
-
|
|
189
|
+
else {
|
|
190
|
+
this.log.trace('No plugins found that can handle request by CID code; exiting pipeline.');
|
|
191
|
+
break;
|
|
295
192
|
}
|
|
296
|
-
// fall-through simulates following the redirect?
|
|
297
|
-
resource = `${resource}/`;
|
|
298
|
-
redirected = true;
|
|
299
193
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
+
}
|
|
315
231
|
}
|
|
316
|
-
|
|
317
|
-
|
|
232
|
+
if (pluginHandled && finalResponse != null) {
|
|
233
|
+
break;
|
|
318
234
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
byteRangeContext.setFileSize(terminalElement.unixfs.fileSize());
|
|
323
|
-
this.log.trace('fileSize for rangeRequest %d', byteRangeContext.getFileSize());
|
|
324
|
-
}
|
|
325
|
-
const offset = byteRangeContext.offset;
|
|
326
|
-
const length = byteRangeContext.length;
|
|
327
|
-
this.log.trace('calling exporter for %c/%s with offset=%o & length=%o', resolvedCID, path, offset, length);
|
|
328
|
-
try {
|
|
329
|
-
const entry = await this.handleServerTiming('exporter-file', '', async () => exporter(resolvedCID, this.helia.blockstore, {
|
|
330
|
-
signal: options?.signal,
|
|
331
|
-
onProgress: options?.onProgress
|
|
332
|
-
}), withServerTiming);
|
|
333
|
-
const asyncIter = entry.content({
|
|
334
|
-
signal: options?.signal,
|
|
335
|
-
onProgress: options?.onProgress,
|
|
336
|
-
offset,
|
|
337
|
-
length
|
|
338
|
-
});
|
|
339
|
-
this.log('got async iterator for %c/%s', cid, path);
|
|
340
|
-
const { stream, firstChunk } = await this.handleServerTiming('stream-and-chunk', '', async () => getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
|
|
341
|
-
onProgress: options?.onProgress,
|
|
342
|
-
signal: options?.signal
|
|
343
|
-
}), withServerTiming);
|
|
344
|
-
byteRangeContext.setBody(stream);
|
|
345
|
-
// if not a valid range request, okRangeRequest will call okResponse
|
|
346
|
-
const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log: this.log }, {
|
|
347
|
-
redirected
|
|
348
|
-
});
|
|
349
|
-
await this.handleServerTiming('set-content-type', '', async () => setContentType({ bytes: firstChunk, path, response, contentTypeParser: this.contentTypeParser, log: this.log }), withServerTiming);
|
|
350
|
-
setIpfsRoots(response, ipfsRoots);
|
|
351
|
-
return response;
|
|
352
|
-
}
|
|
353
|
-
catch (err) {
|
|
354
|
-
options?.signal?.throwIfAborted();
|
|
355
|
-
this.log.error('error streaming %c/%s', cid, path, err);
|
|
356
|
-
if (byteRangeContext.isRangeRequest && err.code === 'ERR_INVALID_PARAMS') {
|
|
357
|
-
return badRangeResponse(resource);
|
|
235
|
+
if (!contextChanged) {
|
|
236
|
+
this.log.trace('No context changes and no final response; exiting pipeline.');
|
|
237
|
+
break;
|
|
358
238
|
}
|
|
359
|
-
return badGatewayResponse(resource.toString(), 'Unable to stream content');
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
async handleRaw({ resource, cid, path, session, options, accept }) {
|
|
363
|
-
/**
|
|
364
|
-
* if we have a path, we can't walk it, so we need to return a 404.
|
|
365
|
-
*
|
|
366
|
-
* @see https://github.com/ipfs/gateway-conformance/blob/26994cfb056b717a23bf694ce4e94386728748dd/tests/subdomain_gateway_ipfs_test.go#L198-L204
|
|
367
|
-
*/
|
|
368
|
-
if (path !== '') {
|
|
369
|
-
this.log.trace('404-ing raw codec request for %c/%s', cid, path);
|
|
370
|
-
return notFoundResponse(resource, 'Raw codec does not support paths');
|
|
371
|
-
}
|
|
372
|
-
const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers);
|
|
373
|
-
const blockstore = this.getBlockstore(cid, resource, session, options);
|
|
374
|
-
const result = await blockstore.get(cid, options);
|
|
375
|
-
byteRangeContext.setBody(result);
|
|
376
|
-
const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log: this.log }, {
|
|
377
|
-
redirected: false
|
|
378
|
-
});
|
|
379
|
-
// if the user has specified an `Accept` header that corresponds to a raw
|
|
380
|
-
// type, honour that header, so for example they don't request
|
|
381
|
-
// `application/vnd.ipld.raw` but get `application/octet-stream`
|
|
382
|
-
await setContentType({ bytes: result, path, response, defaultContentType: getOverridenRawContentType({ headers: options?.headers, accept }), contentTypeParser: this.contentTypeParser, log: this.log });
|
|
383
|
-
return response;
|
|
384
|
-
}
|
|
385
|
-
/**
|
|
386
|
-
* If the user has not specified an Accept header or format query string arg,
|
|
387
|
-
* use the CID codec to choose an appropriate handler for the block data.
|
|
388
|
-
*/
|
|
389
|
-
codecHandlers = {
|
|
390
|
-
[dagPbCode]: this.handleDagPb,
|
|
391
|
-
[ipldDagJson.code]: this.handleJson,
|
|
392
|
-
[jsonCode]: this.handleJson,
|
|
393
|
-
[ipldDagCbor.code]: this.handleDagCbor,
|
|
394
|
-
[rawCode]: this.handleRaw,
|
|
395
|
-
[identity.code]: this.handleRaw
|
|
396
|
-
};
|
|
397
|
-
async handleServerTiming(name, description, fn, withServerTiming) {
|
|
398
|
-
if (!withServerTiming) {
|
|
399
|
-
return fn();
|
|
400
|
-
}
|
|
401
|
-
const { error, result, header } = await serverTiming(name, description, fn);
|
|
402
|
-
this.serverTimingHeaders.push(header);
|
|
403
|
-
if (error != null) {
|
|
404
|
-
throw error;
|
|
405
239
|
}
|
|
406
|
-
return
|
|
407
|
-
}
|
|
408
|
-
/**
|
|
409
|
-
* The last place a Response touches in verified-fetch before being returned to the user. This is where we add the
|
|
410
|
-
* Server-Timing header to the response if it has been collected. It should be used for any final processing of the
|
|
411
|
-
* response before it is returned to the user.
|
|
412
|
-
*/
|
|
413
|
-
handleFinalResponse(response) {
|
|
414
|
-
if (this.serverTimingHeaders.length > 0) {
|
|
415
|
-
const headerString = this.serverTimingHeaders.join(', ');
|
|
416
|
-
response.headers.set('Server-Timing', headerString);
|
|
417
|
-
this.serverTimingHeaders = [];
|
|
418
|
-
}
|
|
419
|
-
return response;
|
|
240
|
+
return finalResponse;
|
|
420
241
|
}
|
|
421
242
|
/**
|
|
422
243
|
* We're starting to get to the point where we need a queue or pipeline of
|
|
@@ -459,70 +280,26 @@ export class VerifiedFetch {
|
|
|
459
280
|
if (acceptHeader != null && accept == null) {
|
|
460
281
|
return this.handleFinalResponse(notAcceptableResponse(resource.toString()));
|
|
461
282
|
}
|
|
462
|
-
|
|
463
|
-
let reqFormat;
|
|
283
|
+
const responseContentType = accept?.split(';')[0] ?? 'application/octet-stream';
|
|
464
284
|
const redirectResponse = await getRedirectResponse({ resource, options, logger: this.helia.logger, cid });
|
|
465
285
|
if (redirectResponse != null) {
|
|
466
286
|
return this.handleFinalResponse(redirectResponse);
|
|
467
287
|
}
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
reqFormat = 'ipns-record';
|
|
472
|
-
response = await this.handleIPNSRecord(handlerArgs);
|
|
473
|
-
}
|
|
474
|
-
else if (accept === 'application/vnd.ipld.car') {
|
|
475
|
-
// the user requested a CAR file
|
|
476
|
-
reqFormat = 'car';
|
|
477
|
-
query.download = true;
|
|
478
|
-
query.filename = query.filename ?? `${cid.toString()}.car`;
|
|
479
|
-
response = await this.handleCar(handlerArgs);
|
|
480
|
-
}
|
|
481
|
-
else if (accept === 'application/vnd.ipld.raw') {
|
|
482
|
-
// the user requested a raw block
|
|
483
|
-
reqFormat = 'raw';
|
|
484
|
-
query.download = true;
|
|
485
|
-
query.filename = query.filename ?? `${cid.toString()}.bin`;
|
|
486
|
-
response = await this.handleRaw(handlerArgs);
|
|
487
|
-
}
|
|
488
|
-
else if (accept === 'application/x-tar') {
|
|
489
|
-
// the user requested a TAR file
|
|
490
|
-
reqFormat = 'tar';
|
|
491
|
-
query.download = true;
|
|
492
|
-
query.filename = query.filename ?? `${cid.toString()}.tar`;
|
|
493
|
-
response = await this.handleTar(handlerArgs);
|
|
494
|
-
}
|
|
495
|
-
else {
|
|
496
|
-
this.log.trace('finding handler for cid code "%s" and output type "%s"', cid.code, accept);
|
|
497
|
-
// derive the handler from the CID type
|
|
498
|
-
const codecHandler = this.codecHandlers[cid.code];
|
|
499
|
-
if (codecHandler == null) {
|
|
500
|
-
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`));
|
|
501
|
-
}
|
|
502
|
-
this.log.trace('calling handler "%s"', codecHandler.name);
|
|
503
|
-
response = await codecHandler.call(this, handlerArgs);
|
|
504
|
-
}
|
|
505
|
-
response.headers.set('etag', getETag({ cid, reqFormat, weak: false }));
|
|
506
|
-
setCacheControlHeader({ response, ttl, protocol });
|
|
507
|
-
response.headers.set('X-Ipfs-Path', ipfsPath);
|
|
508
|
-
// set Content-Disposition header
|
|
509
|
-
let contentDisposition;
|
|
510
|
-
// force download if requested
|
|
511
|
-
if (query.download === true) {
|
|
512
|
-
contentDisposition = 'attachment';
|
|
513
|
-
}
|
|
514
|
-
// override filename if requested
|
|
515
|
-
if (query.filename != null) {
|
|
516
|
-
if (contentDisposition == null) {
|
|
517
|
-
contentDisposition = 'inline';
|
|
518
|
-
}
|
|
519
|
-
contentDisposition = `${contentDisposition}; ${getContentDispositionFilename(query.filename)}`;
|
|
520
|
-
}
|
|
521
|
-
if (contentDisposition != null) {
|
|
522
|
-
response.headers.set('Content-Disposition', contentDisposition);
|
|
523
|
-
}
|
|
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);
|
|
524
291
|
options?.onProgress?.(new CustomProgressEvent('verified-fetch:request:end', { cid, path }));
|
|
525
|
-
return this.handleFinalResponse(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
|
+
});
|
|
526
303
|
}
|
|
527
304
|
/**
|
|
528
305
|
* Start the Helia instance
|