@helia/verified-fetch
Version:
A fetch-like API for obtaining verified & trustless IPFS content on the web
202 lines • 9.97 kB
JavaScript
import { unixfs } from '@helia/unixfs';
import { code as dagPbCode } from '@ipld/dag-pb';
import { AbortError } from '@libp2p/interface';
import { exporter } from 'ipfs-unixfs-exporter';
import { CustomProgressEvent } from 'progress-events';
import { getContentType } from '../utils/get-content-type.js';
import { getStreamFromAsyncIterable } from '../utils/get-stream-from-async-iterable.js';
import { setIpfsRoots } from '../utils/response-headers.js';
import { badGatewayResponse, badRangeResponse, movedPermanentlyResponse, notSupportedResponse, okRangeResponse } from '../utils/responses.js';
import { BasePlugin } from './plugin-base.js';
/**
* Handles UnixFS and dag-pb content.
*/
export class DagPbPlugin extends BasePlugin {
id = 'dag-pb-plugin';
codes = [dagPbCode];
canHandle({ cid, accept, pathDetails, byteRangeContext }) {
this.log('checking if we can handle %c with accept %s', cid, accept);
if (pathDetails == null) {
return false;
}
if (byteRangeContext == null) {
return false;
}
return cid.code === dagPbCode;
}
/**
* @see https://specs.ipfs.tech/http-gateways/path-gateway/#use-in-directory-url-normalization
*/
getRedirectUrl(context) {
const { resource, path } = context;
const redirectCheckNeeded = path === '' ? !resource.toString().endsWith('/') : !path.endsWith('/');
if (redirectCheckNeeded) {
try {
const url = new URL(resource.toString());
if (url.pathname.endsWith('/')) {
// url already has a trailing slash
return null;
}
// make sure we append slash to end of the path
url.pathname = `${url.pathname}/`;
return url.toString();
}
catch (err) {
// resource is likely a CID
return `${resource.toString()}/`;
}
}
return null;
}
async handle(context) {
const { cid, options, withServerTiming = false, pathDetails, query } = context;
const { handleServerTiming, contentTypeParser, helia, getBlockstore } = this.pluginOptions;
const log = this.log;
let resource = context.resource;
let path = context.path;
let redirected = false;
const byteRangeContext = context.byteRangeContext;
const ipfsRoots = pathDetails.ipfsRoots;
const terminalElement = pathDetails.terminalElement;
let resolvedCID = terminalElement.cid;
const fs = unixfs({ ...helia, blockstore: getBlockstore(context.cid, context.resource, options?.session ?? true, options) });
if (terminalElement?.type === 'directory') {
const dirCid = terminalElement.cid;
const redirectUrl = this.getRedirectUrl(context);
if (redirectUrl != null) {
log.trace('directory url normalization spec requires redirect...');
if (options?.redirect === 'error') {
log('could not redirect to %s as redirect option was set to "error"', redirectUrl);
throw new TypeError('Failed to fetch');
}
else if (options?.redirect === 'manual') {
log('returning 301 permanent redirect to %s', redirectUrl);
return movedPermanentlyResponse(resource, redirectUrl);
}
log('following redirect to %s', redirectUrl);
// fall-through simulates following the redirect?
resource = redirectUrl;
redirected = true;
}
const rootFilePath = 'index.html';
try {
log.trace('found directory at %c/%s, looking for index.html', cid, path);
const entry = await handleServerTiming('exporter-dir', '', async () => exporter(`/ipfs/${dirCid}/${rootFilePath}`, helia.blockstore, {
signal: options?.signal,
onProgress: options?.onProgress
}), withServerTiming);
log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, entry.cid);
path = rootFilePath;
resolvedCID = entry.cid;
}
catch (err) {
if (options?.signal?.aborted) {
throw new AbortError(options?.signal?.reason);
}
this.log.error('error loading path %c/%s', dirCid, rootFilePath, err);
context.isDirectory = true;
context.directoryEntries = [];
context.modified++;
this.log.trace('attempting to get directory entries because index.html was not found');
try {
for await (const dirItem of fs.ls(dirCid, { signal: options?.signal, onProgress: options?.onProgress })) {
context.directoryEntries.push(dirItem);
}
// dir-index-html plugin or dir-index-json (future idea?) plugin should handle this
return null;
}
catch (e) {
log.error('error listing directory %c', dirCid, e);
return notSupportedResponse('Unable to get directory contents');
}
}
finally {
options?.onProgress?.(new CustomProgressEvent('verified-fetch:request:end', { cid: dirCid, path: rootFilePath }));
}
}
try {
// attempt to get the exact file size, but timeout quickly.
const stat = await fs.stat(resolvedCID, { extended: true, signal: AbortSignal.timeout(500) });
byteRangeContext.setFileSize(stat.size);
}
catch (err) {
log.error('error getting exact file size for %c/%s - %e', cid, path, err);
byteRangeContext.setFileSize(pathDetails.terminalElement.size);
log.trace('using terminal element size of %d for %c/%s', pathDetails.terminalElement.size, cid, path);
}
try {
const entry = await handleServerTiming('exporter-file', '', async () => exporter(resolvedCID, helia.blockstore, {
signal: options?.signal,
onProgress: options?.onProgress
}), withServerTiming);
let firstChunk;
let contentType;
if (byteRangeContext.isValidRangeRequest) {
contentType = await this.handleRangeRequest(context, entry);
}
else {
const asyncIter = entry.content({
signal: options?.signal,
onProgress: options?.onProgress
});
log('got async iterator for %c/%s', cid, path);
const streamAndFirstChunk = await handleServerTiming('stream-and-chunk', '', async () => getStreamFromAsyncIterable(asyncIter, path ?? '', this.pluginOptions.logger, {
onProgress: options?.onProgress,
signal: options?.signal
}), withServerTiming);
const stream = streamAndFirstChunk.stream;
firstChunk = streamAndFirstChunk.firstChunk;
contentType = await handleServerTiming('get-content-type', '', async () => getContentType({ filename: query.filename, bytes: firstChunk, path, contentTypeParser, log }), withServerTiming);
byteRangeContext.setBody(stream);
}
// if not a valid range request, okRangeRequest will call okResponse
const response = okRangeResponse(resource, byteRangeContext.getBody(contentType), { byteRangeContext, log }, {
redirected
});
response.headers.set('Content-Type', byteRangeContext.getContentType() ?? contentType);
setIpfsRoots(response, ipfsRoots);
return response;
}
catch (err) {
if (options?.signal?.aborted) {
throw new AbortError(options?.signal?.reason);
}
log.error('error streaming %c/%s', cid, path, err);
if (byteRangeContext.isRangeRequest && err.code === 'ERR_INVALID_PARAMS') {
return badRangeResponse(resource);
}
return badGatewayResponse(resource.toString(), 'Unable to stream content');
}
}
async handleRangeRequest(context, entry) {
const { path, byteRangeContext, options, withServerTiming = false } = context;
const { handleServerTiming, contentTypeParser } = this.pluginOptions;
const log = this.log;
// get the first chunk in order to determine the content type
const asyncIter = entry.content({
signal: options?.signal,
onProgress: options?.onProgress,
offset: 0,
// 8kb in order to determine the content type
length: 8192
});
const { firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.pluginOptions.logger, {
onProgress: options?.onProgress,
signal: options?.signal
});
const contentType = await handleServerTiming('get-content-type', '', async () => getContentType({ bytes: firstChunk, path, contentTypeParser, log }), withServerTiming);
byteRangeContext?.setBody((range) => {
if (options?.signal?.aborted) {
throw new AbortError(options?.signal?.reason ?? 'aborted while streaming');
}
return entry.content({
signal: options?.signal,
onProgress: options?.onProgress,
offset: range.start ?? 0,
length: byteRangeContext.getLength(range)
});
}, contentType);
return contentType;
}
}
//# sourceMappingURL=plugin-handle-dag-pb.js.map