diff --git a/public/locales/en/explore.json b/public/locales/en/explore.json index b6422f5d..5a820d58 100644 --- a/public/locales/en/explore.json +++ b/public/locales/en/explore.json @@ -69,6 +69,14 @@ } }, "errors": { - "BlockFetchTimeoutError": "Failed to fetch content in {timeout}s. Please refresh the page to retry or try a different CID." + "BlockFetchTimeoutError": "Failed to fetch content in {timeout}s. Please refresh the page to retry or try a different CID/CAR file.", + "CARFetchError": "Failed to import CAR file: {message}", + "checkIpfsNetwork": "Check content availability on IPFS Network", + "troubleshootingTips": { + "title": "Troubleshooting Tips", + "checkCarFormat": "Check if the file is a valid CAR format", + "tryDifferentCar": "Try uploading a different CAR file" + }, + "clearError": "Okay, take me back to start exploring page" } } diff --git a/src/components/StartExploringPage.tsx b/src/components/StartExploringPage.tsx index 2083d586..9a02c6be 100644 --- a/src/components/StartExploringPage.tsx +++ b/src/components/StartExploringPage.tsx @@ -4,7 +4,9 @@ import { useTranslation } from 'react-i18next' import ReactJoyride from 'react-joyride' import { type ExplorePageLink, explorePageLinks } from '../lib/explore-page-suggestions.js' import { projectsTour } from '../lib/tours.js' +import { useExplore } from '../providers/explore.js' import { AboutIpld } from './about/AboutIpld.js' +import { IpldExploreErrorComponent } from './explore/IpldExploreErrorComponent.js' import IpldExploreForm from './explore/IpldExploreForm.js' import { type NodeStyle, colorForNode, nameForNode, shortNameForNode } from './object-info/ObjectInfo.js' @@ -29,6 +31,9 @@ interface StartExploringPageProps { export const StartExploringPage: React.FC = ({ embed, runTour = false, joyrideCallback, links }) => { const { t } = useTranslation('explore') + const { exploreState } = useExplore() + + const { error } = exploreState return (
@@ -37,22 +42,35 @@ export const StartExploringPage: React.FC = ({ embed, r
-
-

{t('StartExploringPage.header')}

-

{t('StartExploringPage.leadParagraph')}

-
+ {error == null && ( +
+

{t('StartExploringPage.header')}

+

{t('StartExploringPage.leadParagraph')}

+
+ )} {embed != null ? : null} -
    - {(links ?? explorePageLinks).map((suggestion) => ( -
  • - -
  • - ))} -
-
-
- + {error != null + ? ( +
+ +
+ ) + : null} + {error == null && ( +
    + {(links ?? explorePageLinks).map((suggestion) => ( +
  • + +
  • + ))} +
+ )}
+ {error == null && ( +
+ +
+ )}
{ const { t } = useTranslation('explore') - // const [file, setFile] = useState({}) const { doUploadUserProvidedCar } = useExplore() const { selectHeliaReady } = useHelia() @@ -35,6 +34,7 @@ export const IpldCarExploreForm: React.FC = () => { console.error('no file selected') return } + void doUploadUserProvidedCar(selectedFile, uploadImage) }, [doUploadUserProvidedCar]) diff --git a/src/components/explore/IpldExploreErrorComponent.tsx b/src/components/explore/IpldExploreErrorComponent.tsx index 842841d7..6eb0d6c5 100644 --- a/src/components/explore/IpldExploreErrorComponent.tsx +++ b/src/components/explore/IpldExploreErrorComponent.tsx @@ -1,18 +1,57 @@ import React from 'react' import { useTranslation } from 'react-i18next' +import { CARFetchError } from '../../lib/errors' +import { useExplore } from '../../providers/explore.js' import type IpldExploreError from '../../lib/errors' +import type { ExploreState } from '../../providers/explore.js' export interface IpldExploreErrorComponentProps { error: IpldExploreError | null } -export function IpldExploreErrorComponent ({ error }: IpldExploreErrorComponentProps): JSX.Element | null { +const CIDTroubleshootingTips: React.FC = () => { const { t } = useTranslation('explore', { keyPrefix: 'errors' }) - if (error == null) return null + const { setExploreState } = useExplore() + + const handleClearError = (): void => { + const hashSplit = window.location.hash.split('/') + if (hashSplit.length > 2) { + window.location.hash = '#/explore' + } + setExploreState((state: ExploreState) => ({ ...state, path: hashSplit.length > 2 ? '/' : state.path, error: null })) + } return ( -
-
{error.toString(t)}
-
+ <> +
+

{t('troubleshootingTips.title')}

+
    +
  • {t('troubleshootingTips.checkCarFormat')}
  • +
  • {t('troubleshootingTips.tryDifferentCar')}
  • +
+
+
+ +
+ ) } + +export function IpldExploreErrorComponent ({ error }: IpldExploreErrorComponentProps): JSX.Element | null { + const { t } = useTranslation('explore', { keyPrefix: 'errors' }) + if (error == null) return null + + return
+
+
{error.toString(t)}
+ {error instanceof CARFetchError + ? + : null} +
+
+} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 5b01f086..e672d90e 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -22,3 +22,5 @@ export default class IpldExploreError extends Error { } export class BlockFetchTimeoutError extends IpldExploreError {} + +export class CARFetchError extends IpldExploreError {} diff --git a/src/lib/import-car.ts b/src/lib/import-car.ts index e8646c68..8c256410 100644 --- a/src/lib/import-car.ts +++ b/src/lib/import-car.ts @@ -2,6 +2,7 @@ import { type Helia } from '@helia/interface' import { CarBlockIterator } from '@ipld/car' import { type CID } from 'multiformats' import { source } from 'stream-to-it' +import { BlockFetchTimeoutError } from './errors.js' /** * Given a file object representing a CAR archive, import it into the given Helia instance, @@ -9,15 +10,39 @@ import { source } from 'stream-to-it' * * TODO: Handle multiple roots */ -export async function importCar (file: File, helia: Helia): Promise { - const inStream = file.stream() - const CarIterator = await CarBlockIterator.fromIterable(source(inStream)) - for await (const { cid, bytes } of CarIterator) { - // add blocks to helia to ensure they are available while navigating children - await helia.blockstore.put(cid, bytes) - } - const cidRoots = await CarIterator.getRoots() +export async function importCar (file: File, helia: Helia, timeout = 30000): Promise { + const controller = new AbortController() + const { signal } = controller + + const timeoutId = setTimeout(() => { + controller.abort('Request timed out') + }, timeout) + + try { + const inStream = file.stream() - // @todo: Handle multiple roots - return cidRoots[0] + const CarIterator = await CarBlockIterator.fromIterable(source(inStream)) + + for await (const { cid, bytes } of CarIterator) { + if (signal.aborted) { + throw new BlockFetchTimeoutError({ timeout: timeout / 1000 }) + } + // add blocks to helia to ensure they are available while navigating children + await helia.blockstore.put(cid, bytes) + } + + const cidRoots = await CarIterator.getRoots() + if (cidRoots.length === 0) { + throw new Error('Invalid CAR file: no roots found') + } + // @todo: Handle multiple roots + return cidRoots[0] + } catch (err) { + if (err instanceof Error && err.message === 'Unexpected end of data') { + throw new Error('Invalid CAR file') + } + throw new Error(err instanceof Error ? err.message : 'Failed to import CAR file') + } finally { + clearTimeout(timeoutId) + } } diff --git a/src/providers/explore.tsx b/src/providers/explore.tsx index bf62533e..d2847405 100644 --- a/src/providers/explore.tsx +++ b/src/providers/explore.tsx @@ -1,6 +1,7 @@ import { CID } from 'multiformats/cid' import React, { createContext, useContext, useState, useEffect, type ReactNode, useCallback } from 'react' import { type LinkObject } from '../components/object-info/links-table' +import { CARFetchError } from '../lib/errors.js' import { ensureLeadingSlash } from '../lib/helpers.js' import { importCar } from '../lib/import-car.js' import { parseIpldPath } from '../lib/parse-ipld-path.js' @@ -17,6 +18,7 @@ interface ExploreContextProps { doExploreLink(link: any): void doExploreUserProvidedPath(path: string): void doUploadUserProvidedCar(file: File, uploadImage: string): Promise + setExploreState: React.Dispatch> } export interface ExploreState { @@ -185,26 +187,48 @@ export const ExploreProvider = ({ children, state, explorePathPrefix = '#/explor } const doUploadUserProvidedCar = useCallback(async (file: File, uploadImage: string): Promise => { + const resolveLoader = (image: string): void => { + const imageFileLoader = document.getElementById('car-loader-image') as HTMLImageElement + if (imageFileLoader != null) { + imageFileLoader.src = image + } + } + if (helia == null) { console.error('FIXME: Helia not ready yet, but user tried to upload a car file') return } + try { + if (file.size === 0) { + throw new Error('CAR file is empty') + } + const rootCid = await importCar(file, helia) const hash = rootCid.toString() != null ? `${explorePathPrefix}${ensureLeadingSlash(rootCid.toString())}` : explorePathPrefix window.location.hash = hash - const imageFileLoader = document.getElementById('car-loader-image') as HTMLImageElement - if (imageFileLoader != null) { - imageFileLoader.src = uploadImage - } + resolveLoader(uploadImage) } catch (err) { console.error('Could not import car file', err) + + setExploreState((prevState) => ({ + ...prevState, + targetNode: null, + error: new CARFetchError({ message: err instanceof Error ? err.message : 'Failed to import CAR file' }) + })) + + resolveLoader(uploadImage) + + const carFileInputEl = document.getElementById('car-file') as HTMLInputElement + if (carFileInputEl != null) { + carFileInputEl.value = '' + } } }, [explorePathPrefix, helia]) return ( - + {children} )