@helia/verified-fetch 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +200 -0
  2. package/dist/index.min.js +357 -35
  3. package/dist/src/index.d.ts +220 -0
  4. package/dist/src/index.d.ts.map +1 -1
  5. package/dist/src/index.js +200 -0
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/plugins/errors.d.ts +25 -0
  8. package/dist/src/plugins/errors.d.ts.map +1 -0
  9. package/dist/src/plugins/errors.js +33 -0
  10. package/dist/src/plugins/errors.js.map +1 -0
  11. package/dist/src/plugins/index.d.ts +8 -0
  12. package/dist/src/plugins/index.d.ts.map +1 -0
  13. package/dist/src/plugins/index.js +7 -0
  14. package/dist/src/plugins/index.js.map +1 -0
  15. package/dist/src/plugins/plugin-base.d.ts +19 -0
  16. package/dist/src/plugins/plugin-base.d.ts.map +1 -0
  17. package/dist/src/plugins/plugin-base.js +26 -0
  18. package/dist/src/plugins/plugin-base.js.map +1 -0
  19. package/dist/src/plugins/plugin-handle-car.d.ts +11 -0
  20. package/dist/src/plugins/plugin-handle-car.d.ts.map +1 -0
  21. package/dist/src/plugins/plugin-handle-car.js +28 -0
  22. package/dist/src/plugins/plugin-handle-car.js.map +1 -0
  23. package/dist/src/plugins/plugin-handle-dag-cbor.d.ts +11 -0
  24. package/dist/src/plugins/plugin-handle-dag-cbor.d.ts.map +1 -0
  25. package/dist/src/plugins/plugin-handle-dag-cbor.js +73 -0
  26. package/dist/src/plugins/plugin-handle-dag-cbor.js.map +1 -0
  27. package/dist/src/plugins/plugin-handle-dag-pb.d.ts +15 -0
  28. package/dist/src/plugins/plugin-handle-dag-pb.d.ts.map +1 -0
  29. package/dist/src/plugins/plugin-handle-dag-pb.js +152 -0
  30. package/dist/src/plugins/plugin-handle-dag-pb.js.map +1 -0
  31. package/dist/src/plugins/plugin-handle-dag-walk.d.ts +16 -0
  32. package/dist/src/plugins/plugin-handle-dag-walk.d.ts.map +1 -0
  33. package/dist/src/plugins/plugin-handle-dag-walk.js +45 -0
  34. package/dist/src/plugins/plugin-handle-dag-walk.js.map +1 -0
  35. package/dist/src/plugins/plugin-handle-dir-index-html.d.ts +9 -0
  36. package/dist/src/plugins/plugin-handle-dir-index-html.d.ts.map +1 -0
  37. package/dist/src/plugins/plugin-handle-dir-index-html.js +37 -0
  38. package/dist/src/plugins/plugin-handle-dir-index-html.js.map +1 -0
  39. package/dist/src/plugins/plugin-handle-ipns-record.d.ts +12 -0
  40. package/dist/src/plugins/plugin-handle-ipns-record.d.ts.map +1 -0
  41. package/dist/src/plugins/plugin-handle-ipns-record.js +62 -0
  42. package/dist/src/plugins/plugin-handle-ipns-record.js.map +1 -0
  43. package/dist/src/plugins/plugin-handle-json.d.ts +11 -0
  44. package/dist/src/plugins/plugin-handle-json.d.ts.map +1 -0
  45. package/dist/src/plugins/plugin-handle-json.js +51 -0
  46. package/dist/src/plugins/plugin-handle-json.js.map +1 -0
  47. package/dist/src/plugins/plugin-handle-raw.d.ts +8 -0
  48. package/dist/src/plugins/plugin-handle-raw.d.ts.map +1 -0
  49. package/dist/src/plugins/plugin-handle-raw.js +80 -0
  50. package/dist/src/plugins/plugin-handle-raw.js.map +1 -0
  51. package/dist/src/plugins/plugin-handle-tar.d.ts +12 -0
  52. package/dist/src/plugins/plugin-handle-tar.d.ts.map +1 -0
  53. package/dist/src/plugins/plugin-handle-tar.js +36 -0
  54. package/dist/src/plugins/plugin-handle-tar.js.map +1 -0
  55. package/dist/src/plugins/plugins.d.ts +5 -0
  56. package/dist/src/plugins/plugins.d.ts.map +1 -0
  57. package/dist/src/plugins/plugins.js +5 -0
  58. package/dist/src/plugins/plugins.js.map +1 -0
  59. package/dist/src/plugins/types.d.ts +68 -0
  60. package/dist/src/plugins/types.d.ts.map +1 -0
  61. package/dist/src/plugins/types.js +2 -0
  62. package/dist/src/plugins/types.js.map +1 -0
  63. package/dist/src/types.d.ts +0 -23
  64. package/dist/src/types.d.ts.map +1 -1
  65. package/dist/src/types.js +1 -2
  66. package/dist/src/types.js.map +1 -1
  67. package/dist/src/utils/dir-index-html.d.ts +16 -0
  68. package/dist/src/utils/dir-index-html.d.ts.map +1 -0
  69. package/dist/src/utils/dir-index-html.js +387 -0
  70. package/dist/src/utils/dir-index-html.js.map +1 -0
  71. package/dist/src/utils/get-e-tag.d.ts +1 -1
  72. package/dist/src/utils/get-e-tag.d.ts.map +1 -1
  73. package/dist/src/utils/get-e-tag.js +18 -3
  74. package/dist/src/utils/get-e-tag.js.map +1 -1
  75. package/dist/src/utils/parse-resource.d.ts +2 -1
  76. package/dist/src/utils/parse-resource.d.ts.map +1 -1
  77. package/dist/src/utils/parse-resource.js +4 -3
  78. package/dist/src/utils/parse-resource.js.map +1 -1
  79. package/dist/src/utils/parse-url-string.d.ts +8 -3
  80. package/dist/src/utils/parse-url-string.d.ts.map +1 -1
  81. package/dist/src/utils/parse-url-string.js +30 -4
  82. package/dist/src/utils/parse-url-string.js.map +1 -1
  83. package/dist/src/utils/server-timing.d.ts +13 -0
  84. package/dist/src/utils/server-timing.d.ts.map +1 -0
  85. package/dist/src/utils/server-timing.js +19 -0
  86. package/dist/src/utils/server-timing.js.map +1 -0
  87. package/dist/src/utils/walk-path.d.ts +3 -2
  88. package/dist/src/utils/walk-path.d.ts.map +1 -1
  89. package/dist/src/utils/walk-path.js +1 -1
  90. package/dist/src/utils/walk-path.js.map +1 -1
  91. package/dist/src/verified-fetch.d.ts +11 -20
  92. package/dist/src/verified-fetch.d.ts.map +1 -1
  93. package/dist/src/verified-fetch.js +174 -367
  94. package/dist/src/verified-fetch.js.map +1 -1
  95. package/dist/typedoc-urls.json +32 -24
  96. package/package.json +6 -2
  97. package/src/index.ts +223 -0
  98. package/src/plugins/errors.ts +37 -0
  99. package/src/plugins/index.ts +8 -0
  100. package/src/plugins/plugin-base.ts +30 -0
  101. package/src/plugins/plugin-handle-car.ts +32 -0
  102. package/src/plugins/plugin-handle-dag-cbor.ts +84 -0
  103. package/src/plugins/plugin-handle-dag-pb.ts +168 -0
  104. package/src/plugins/plugin-handle-dag-walk.ts +53 -0
  105. package/src/plugins/plugin-handle-dir-index-html.ts +44 -0
  106. package/src/plugins/plugin-handle-ipns-record.ts +69 -0
  107. package/src/plugins/plugin-handle-json.ts +57 -0
  108. package/src/plugins/plugin-handle-raw.ts +92 -0
  109. package/src/plugins/plugin-handle-tar.ts +44 -0
  110. package/src/plugins/plugins.ts +4 -0
  111. package/src/plugins/types.ts +73 -0
  112. package/src/types.ts +0 -29
  113. package/src/utils/dir-index-html.ts +445 -0
  114. package/src/utils/get-e-tag.ts +20 -3
  115. package/src/utils/parse-resource.ts +5 -4
  116. package/src/utils/parse-url-string.ts +38 -7
  117. package/src/utils/server-timing.ts +37 -0
  118. package/src/utils/walk-path.ts +3 -3
  119. package/src/verified-fetch.ts +198 -403
@@ -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 { 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
- import { setContentType } from './utils/set-content-type.js';
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
- * Accepts an `ipns://...` or `https?://<ipnsname>.ipns.<domain>` URL as a string and returns a `Response` containing
120
- * a raw IPNS record.
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
- let peerId;
128
- try {
129
- if (resource.startsWith('ipns://')) {
130
- const peerIdString = resource.replace('ipns://', '');
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
- catch (err) {
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
- * Accepts a `CID` and returns a `Response` with a body stream that is a CAR
160
- * of the `DAG` 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.
161
125
  */
162
- async handleCar({ resource, cid, session, options }) {
163
- const blockstore = this.getBlockstore(cid, resource, session, options);
164
- const c = car({ blockstore, getCodec: this.helia.getCodec });
165
- const stream = toBrowserReadableStream(c.stream(cid, options));
166
- const response = okResponse(resource, stream);
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
- const blockstore = this.getBlockstore(cid, resource, session, options);
179
- const stream = toBrowserReadableStream(tarStream(`/ipfs/${cid}/${path}`, blockstore, options));
180
- const response = okResponse(resource, stream);
181
- response.headers.set('content-type', 'application/x-tar');
182
- return response;
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
- // skip decoding
204
- body = block;
140
+ this.log.trace('download not requested');
205
141
  }
206
- const response = okResponse(resource, body);
207
- response.headers.set('content-type', accept ?? 'application/json');
208
- return response;
209
- }
210
- async handleDagCbor({ resource, cid, path, accept, session, options }) {
211
- this.log.trace('fetching %c/%s', cid, path);
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
- // this should never happen, but if it does, we should log it and return notSupportedResponse
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
- else if (accept === 'application/vnd.ipld.dag-json') {
235
- try {
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
- try {
249
- body = dagCborToSafeJSON(block);
250
- }
251
- catch (err) {
252
- if (accept === 'application/json') {
253
- this.log('could not decode DAG-CBOR as JSON-safe, but the client sent "Accept: application/json"', err);
254
- return notAcceptableResponse(resource);
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
- const response = okResponse(resource, body);
261
- if (accept == null) {
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
- async handleDagPb({ cid, path, resource, session, options }) {
269
- let redirected = false;
270
- const byteRangeContext = new ByteRangeContext(this.helia.logger, options?.headers);
271
- const blockstore = this.getBlockstore(cid, resource, session, options);
272
- const pathDetails = await handlePathWalking({ cid, path, resource, options, blockstore, log: this.log });
273
- if (pathDetails instanceof Response) {
274
- return pathDetails;
275
- }
276
- const ipfsRoots = pathDetails.ipfsRoots;
277
- const terminalElement = pathDetails.terminalElement;
278
- let resolvedCID = terminalElement.cid;
279
- if (terminalElement?.type === 'directory') {
280
- const dirCid = terminalElement.cid;
281
- const redirectCheckNeeded = path === '' ? !resource.toString().endsWith('/') : !path.endsWith('/');
282
- // https://specs.ipfs.tech/http-gateways/path-gateway/#use-in-directory-url-normalization
283
- if (redirectCheckNeeded) {
284
- if (options?.redirect === 'error') {
285
- this.log('could not redirect to %s/ as redirect option was set to "error"', resource);
286
- 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);
287
188
  }
288
- else if (options?.redirect === 'manual') {
289
- this.log('returning 301 permanent redirect to %s/', resource);
290
- return movedPermanentlyResponse(resource, `${resource}/`);
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
- const rootFilePath = 'index.html';
297
- try {
298
- this.log.trace('found directory at %c/%s, looking for index.html', cid, path);
299
- const entry = await exporter(`/ipfs/${dirCid}/${rootFilePath}`, this.helia.blockstore, {
300
- signal: options?.signal,
301
- onProgress: options?.onProgress
302
- });
303
- this.log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, entry.cid);
304
- path = rootFilePath;
305
- resolvedCID = entry.cid;
306
- }
307
- catch (err) {
308
- options?.signal?.throwIfAborted();
309
- this.log('error loading path %c/%s', dirCid, rootFilePath, err);
310
- 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
+ }
311
231
  }
312
- finally {
313
- options?.onProgress?.(new CustomProgressEvent('verified-fetch:request:end', { cid: dirCid, path: rootFilePath }));
232
+ if (pluginHandled && finalResponse != null) {
233
+ break;
314
234
  }
315
- }
316
- // we have a validRangeRequest & terminalElement is a file, we know the size and should set it
317
- if (byteRangeContext.isRangeRequest && byteRangeContext.isValidRangeRequest && terminalElement.type === 'file') {
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
- let response;
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