@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.
Files changed (108) hide show
  1. package/README.md +192 -0
  2. package/dist/index.min.js +354 -32
  3. package/dist/src/index.d.ts +198 -0
  4. package/dist/src/index.d.ts.map +1 -1
  5. package/dist/src/index.js +192 -0
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/plugins/errors.d.ts +25 -0
  8. package/dist/src/plugins/errors.d.ts.map +1 -0
  9. package/dist/src/plugins/errors.js +33 -0
  10. package/dist/src/plugins/errors.js.map +1 -0
  11. package/dist/src/plugins/index.d.ts +8 -0
  12. package/dist/src/plugins/index.d.ts.map +1 -0
  13. package/dist/src/plugins/index.js +7 -0
  14. package/dist/src/plugins/index.js.map +1 -0
  15. package/dist/src/plugins/plugin-base.d.ts +19 -0
  16. package/dist/src/plugins/plugin-base.d.ts.map +1 -0
  17. package/dist/src/plugins/plugin-base.js +26 -0
  18. package/dist/src/plugins/plugin-base.js.map +1 -0
  19. package/dist/src/plugins/plugin-handle-car.d.ts +11 -0
  20. package/dist/src/plugins/plugin-handle-car.d.ts.map +1 -0
  21. package/dist/src/plugins/plugin-handle-car.js +28 -0
  22. package/dist/src/plugins/plugin-handle-car.js.map +1 -0
  23. package/dist/src/plugins/plugin-handle-dag-cbor.d.ts +11 -0
  24. package/dist/src/plugins/plugin-handle-dag-cbor.d.ts.map +1 -0
  25. package/dist/src/plugins/plugin-handle-dag-cbor.js +73 -0
  26. package/dist/src/plugins/plugin-handle-dag-cbor.js.map +1 -0
  27. package/dist/src/plugins/plugin-handle-dag-pb.d.ts +15 -0
  28. package/dist/src/plugins/plugin-handle-dag-pb.d.ts.map +1 -0
  29. package/dist/src/plugins/plugin-handle-dag-pb.js +152 -0
  30. package/dist/src/plugins/plugin-handle-dag-pb.js.map +1 -0
  31. package/dist/src/plugins/plugin-handle-dag-walk.d.ts +16 -0
  32. package/dist/src/plugins/plugin-handle-dag-walk.d.ts.map +1 -0
  33. package/dist/src/plugins/plugin-handle-dag-walk.js +45 -0
  34. package/dist/src/plugins/plugin-handle-dag-walk.js.map +1 -0
  35. package/dist/src/plugins/plugin-handle-dir-index-html.d.ts +9 -0
  36. package/dist/src/plugins/plugin-handle-dir-index-html.d.ts.map +1 -0
  37. package/dist/src/plugins/plugin-handle-dir-index-html.js +42 -0
  38. package/dist/src/plugins/plugin-handle-dir-index-html.js.map +1 -0
  39. package/dist/src/plugins/plugin-handle-ipns-record.d.ts +12 -0
  40. package/dist/src/plugins/plugin-handle-ipns-record.d.ts.map +1 -0
  41. package/dist/src/plugins/plugin-handle-ipns-record.js +62 -0
  42. package/dist/src/plugins/plugin-handle-ipns-record.js.map +1 -0
  43. package/dist/src/plugins/plugin-handle-json.d.ts +11 -0
  44. package/dist/src/plugins/plugin-handle-json.d.ts.map +1 -0
  45. package/dist/src/plugins/plugin-handle-json.js +51 -0
  46. package/dist/src/plugins/plugin-handle-json.js.map +1 -0
  47. package/dist/src/plugins/plugin-handle-raw.d.ts +8 -0
  48. package/dist/src/plugins/plugin-handle-raw.d.ts.map +1 -0
  49. package/dist/src/plugins/plugin-handle-raw.js +80 -0
  50. package/dist/src/plugins/plugin-handle-raw.js.map +1 -0
  51. package/dist/src/plugins/plugin-handle-tar.d.ts +12 -0
  52. package/dist/src/plugins/plugin-handle-tar.d.ts.map +1 -0
  53. package/dist/src/plugins/plugin-handle-tar.js +36 -0
  54. package/dist/src/plugins/plugin-handle-tar.js.map +1 -0
  55. package/dist/src/plugins/plugins.d.ts +5 -0
  56. package/dist/src/plugins/plugins.d.ts.map +1 -0
  57. package/dist/src/plugins/plugins.js +5 -0
  58. package/dist/src/plugins/plugins.js.map +1 -0
  59. package/dist/src/plugins/types.d.ts +68 -0
  60. package/dist/src/plugins/types.d.ts.map +1 -0
  61. package/dist/src/plugins/types.js +2 -0
  62. package/dist/src/plugins/types.js.map +1 -0
  63. package/dist/src/types.d.ts +0 -27
  64. package/dist/src/types.d.ts.map +1 -1
  65. package/dist/src/types.js +1 -2
  66. package/dist/src/types.js.map +1 -1
  67. package/dist/src/utils/dir-index-html.d.ts +16 -0
  68. package/dist/src/utils/dir-index-html.d.ts.map +1 -0
  69. package/dist/src/utils/dir-index-html.js +384 -0
  70. package/dist/src/utils/dir-index-html.js.map +1 -0
  71. package/dist/src/utils/get-e-tag.d.ts +1 -1
  72. package/dist/src/utils/get-e-tag.d.ts.map +1 -1
  73. package/dist/src/utils/get-e-tag.js +18 -3
  74. package/dist/src/utils/get-e-tag.js.map +1 -1
  75. package/dist/src/utils/response-headers.d.ts.map +1 -1
  76. package/dist/src/utils/response-headers.js +4 -0
  77. package/dist/src/utils/response-headers.js.map +1 -1
  78. package/dist/src/utils/walk-path.d.ts +3 -2
  79. package/dist/src/utils/walk-path.d.ts.map +1 -1
  80. package/dist/src/utils/walk-path.js +1 -1
  81. package/dist/src/utils/walk-path.js.map +1 -1
  82. package/dist/src/verified-fetch.d.ts +6 -24
  83. package/dist/src/verified-fetch.d.ts.map +1 -1
  84. package/dist/src/verified-fetch.js +164 -387
  85. package/dist/src/verified-fetch.js.map +1 -1
  86. package/dist/typedoc-urls.json +32 -24
  87. package/package.json +6 -2
  88. package/src/index.ts +199 -0
  89. package/src/plugins/errors.ts +37 -0
  90. package/src/plugins/index.ts +8 -0
  91. package/src/plugins/plugin-base.ts +30 -0
  92. package/src/plugins/plugin-handle-car.ts +32 -0
  93. package/src/plugins/plugin-handle-dag-cbor.ts +84 -0
  94. package/src/plugins/plugin-handle-dag-pb.ts +168 -0
  95. package/src/plugins/plugin-handle-dag-walk.ts +53 -0
  96. package/src/plugins/plugin-handle-dir-index-html.ts +50 -0
  97. package/src/plugins/plugin-handle-ipns-record.ts +69 -0
  98. package/src/plugins/plugin-handle-json.ts +57 -0
  99. package/src/plugins/plugin-handle-raw.ts +92 -0
  100. package/src/plugins/plugin-handle-tar.ts +44 -0
  101. package/src/plugins/plugins.ts +4 -0
  102. package/src/plugins/types.ts +73 -0
  103. package/src/types.ts +0 -34
  104. package/src/utils/dir-index-html.ts +442 -0
  105. package/src/utils/get-e-tag.ts +20 -3
  106. package/src/utils/response-headers.ts +4 -0
  107. package/src/utils/walk-path.ts +3 -3
  108. 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 { Record as DHTRecord } from '@libp2p/kad-dht';
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 { concat as uint8ArrayConcat } from 'uint8arrays/concat';
18
- import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string';
19
- import { toString as uint8ArrayToString } from 'uint8arrays/to-string';
20
- import { ByteRangeContext } from './utils/byte-range-context.js';
21
- import { dagCborToSafeJSON } from './utils/dag-cbor-to-safe-json.js';
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, setIpfsRoots } from './utils/response-headers.js';
33
- import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse, badRangeResponse, okRangeResponse, badGatewayResponse, notFoundResponse } from './utils/responses.js';
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
- * Accepts an `ipns://...` or `https?://<ipnsname>.ipns.<domain>` URL as a string and returns a `Response` containing
124
- * a raw IPNS record.
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
- catch (err) {
145
- this.log.error('could not parse peer id from IPNS url %s', resource, err);
146
- return badRequestResponse(resource, err);
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
- // since this call happens after parseResource, we've already resolved the
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
- * Accepts a UnixFS `CID` and returns a `.tar` file containing the file or
176
- * directory structure referenced by the `CID`.
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
- async handleTar({ resource, cid, path, session, options }) {
179
- if (cid.code !== dagPbCode && cid.code !== rawCode) {
180
- 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 = [];
181
131
  }
182
- const blockstore = this.getBlockstore(cid, resource, session, options);
183
- const stream = toBrowserReadableStream(tarStream(`/ipfs/${cid}/${path}`, blockstore, options));
184
- const response = okResponse(resource, stream);
185
- response.headers.set('content-type', 'application/x-tar');
186
- return response;
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
- // skip decoding
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
- const ipfsRoots = pathDetails.ipfsRoots;
224
- if (isObjectNode(pathDetails.terminalElement)) {
225
- 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)}`;
226
148
  }
227
149
  else {
228
- // this should never happen, but if it does, we should log it and return notSupportedResponse
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
- const block = terminalElement.node;
233
- let body;
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
- try {
253
- body = dagCborToSafeJSON(block);
254
- }
255
- catch (err) {
256
- if (accept === 'application/json') {
257
- this.log('could not decode DAG-CBOR as JSON-safe, but the client sent "Accept: application/json"', err);
258
- return notAcceptableResponse(resource);
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
- const response = okResponse(resource, body);
265
- if (accept == null) {
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
- async handleDagPb({ cid, path, resource, session, options, withServerTiming }) {
273
- let redirected = false;
274
- const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers);
275
- const blockstore = this.getBlockstore(cid, resource, session, options);
276
- const pathDetails = await this.handleServerTiming('path-walking', '', async () => handlePathWalking({ cid, path, resource, options, blockstore, log: this.log, withServerTiming }), withServerTiming);
277
- if (pathDetails instanceof Response) {
278
- return pathDetails;
279
- }
280
- const ipfsRoots = pathDetails.ipfsRoots;
281
- const terminalElement = pathDetails.terminalElement;
282
- let resolvedCID = terminalElement.cid;
283
- if (terminalElement?.type === 'directory') {
284
- const dirCid = terminalElement.cid;
285
- const redirectCheckNeeded = path === '' ? !resource.toString().endsWith('/') : !path.endsWith('/');
286
- // https://specs.ipfs.tech/http-gateways/path-gateway/#use-in-directory-url-normalization
287
- if (redirectCheckNeeded) {
288
- if (options?.redirect === 'error') {
289
- this.log('could not redirect to %s/ as redirect option was set to "error"', resource);
290
- throw new TypeError('Failed to fetch');
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 if (options?.redirect === 'manual') {
293
- this.log('returning 301 permanent redirect to %s/', resource);
294
- return movedPermanentlyResponse(resource, `${resource}/`);
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
- const rootFilePath = 'index.html';
301
- try {
302
- this.log.trace('found directory at %c/%s, looking for index.html', cid, path);
303
- const entry = await this.handleServerTiming('exporter-dir', '', async () => exporter(`/ipfs/${dirCid}/${rootFilePath}`, this.helia.blockstore, {
304
- signal: options?.signal,
305
- onProgress: options?.onProgress
306
- }), withServerTiming);
307
- this.log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, entry.cid);
308
- path = rootFilePath;
309
- resolvedCID = entry.cid;
310
- }
311
- catch (err) {
312
- options?.signal?.throwIfAborted();
313
- this.log('error loading path %c/%s', dirCid, rootFilePath, err);
314
- return notSupportedResponse('Unable to find index.html for directory at given path. Support for directories with implicit root is not implemented');
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
- finally {
317
- options?.onProgress?.(new CustomProgressEvent('verified-fetch:request:end', { cid: dirCid, path: rootFilePath }));
232
+ if (pluginHandled && finalResponse != null) {
233
+ break;
318
234
  }
319
- }
320
- // we have a validRangeRequest & terminalElement is a file, we know the size and should set it
321
- if (byteRangeContext.isRangeRequest && byteRangeContext.isValidRangeRequest && terminalElement.type === 'file') {
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 result;
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
- let response;
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 handlerArgs = { resource: resource.toString(), cid, path, accept, session: options?.session ?? true, options, withServerTiming };
469
- if (accept === 'application/vnd.ipfs.ipns-record') {
470
- // the user requested a raw IPNS record
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