diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dc63a6a2..c3000c36 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,6 +21,7 @@ jobs:
matrix:
project:
- helia-101
+ - helia-browser-verified-fetch
- helia-cjs
- helia-electron
- helia-esbuild
@@ -86,6 +87,7 @@ jobs:
project:
- helia-101
- helia-cjs
+ - helia-browser-verified-fetch
- helia-electron
- helia-esbuild
- helia-jest
diff --git a/examples/helia-browser-verified-fetch/.gitignore b/examples/helia-browser-verified-fetch/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/examples/helia-browser-verified-fetch/README.md b/examples/helia-browser-verified-fetch/README.md
new file mode 100644
index 00000000..3a117ea4
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/README.md
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+Browser Verified Retrieval with @helia/verified-fetch
+
+
+
+
+ Explore the docs
+ ยท
+
+ ยท
+ Report Bug
+ ยท
+ Request Feature/Example
+
+
+
+## Table of Contents
+
+- [Getting Started](#getting-started)
+ - [Installation and Running example](#installation-and-running-example)
+
+## Getting Started
+
+### Installation and Running example
+
+```console
+npm install
+npm run dev
+```
diff --git a/examples/helia-browser-verified-fetch/index.html b/examples/helia-browser-verified-fetch/index.html
new file mode 100644
index 00000000..314ad51a
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Verified Retrieval with @helia/verified-fetch
+
+
+
+
+
+
diff --git a/examples/helia-browser-verified-fetch/package.json b/examples/helia-browser-verified-fetch/package.json
new file mode 100644
index 00000000..4c319c1a
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "helia-browser-verified-fetch",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "clean": "rimraf ./dist",
+ "start": "vite",
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "test": "vite build -c ./test/vite.config.js && test-browser-example test"
+ },
+ "dependencies": {
+ "@helia/verified-fetch": "^1.3.2",
+ "@sgtpooki/file-type": "^1.0.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.42.1",
+ "@types/react": "^18.2.72",
+ "@types/react-dom": "^18.2.22",
+ "@vitejs/plugin-react": "^4.2.1",
+ "test-ipfs-example": "^1.0.0",
+ "typescript": "^5.4.3",
+ "vite": "^5.2.6"
+ }
+}
diff --git a/examples/helia-browser-verified-fetch/src/App.tsx b/examples/helia-browser-verified-fetch/src/App.tsx
new file mode 100644
index 00000000..4854bf3c
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/src/App.tsx
@@ -0,0 +1,273 @@
+import { verifiedFetch } from '@helia/verified-fetch'
+import { fileTypeFromBuffer } from '@sgtpooki/file-type'
+import { useCallback, useState } from 'react'
+import { Output } from './Output'
+import { helpText } from './constants'
+
+function App (): JSX.Element {
+ const [path, setPath] = useState('')
+ const [output, setOutput] = useState('')
+ const [err, setErr] = useState('')
+ const [loading, setLoadingTo] = useState('')
+ const [controller, setController] = useState(null)
+
+ const setSuccess = useCallback((message: string | JSX.Element) => {
+ setOutput(message)
+ setLoadingTo('')
+ setErr('')
+ }, [])
+ const setError = useCallback((message: string) => {
+ setOutput('')
+ setLoadingTo('')
+ setErr(message)
+ }, [])
+ const setLoading = useCallback((message: string) => {
+ setErr('')
+ setLoadingTo(message)
+ }, [])
+
+ const handleImageType = useCallback(async (resp: Response) => {
+ try {
+ setLoading('Waiting for full image data...')
+ const blob = await resp.blob()
+ const url = URL.createObjectURL(blob)
+ setSuccess()
+ } catch (err) {
+ setError((err as Error).message)
+ }
+ }, [])
+
+ const handleJsonType = useCallback(async (resp: Response) => {
+ try {
+ setLoading('Waiting for full JSON data...')
+ const json = await resp.json()
+ setSuccess(JSON.stringify(json, null, 2))
+ } catch (err) {
+ setError((err as Error).message)
+ }
+ }, [])
+
+ const handleVideoType = useCallback(async (resp: Response) => {
+ try {
+ controller?.abort() // abort any ongoing requests
+ setLoading('Waiting for full video data...')
+ const blob = await resp.blob()
+ const url = URL.createObjectURL(blob)
+ setSuccess()
+ } catch (err) {
+ setError((err as Error).message)
+ }
+ }, [])
+
+ const onFetchJson = useCallback(async (jsonType: 'json' | 'dag-json' = 'json') => {
+ try {
+ controller?.abort() // abort any ongoing requests
+ setLoading(`Fetching ${jsonType} response...`)
+ const ctl = new AbortController()
+ setController(ctl)
+ const resp = await verifiedFetch(path, {
+ signal: ctl.signal,
+ headers: {
+ accept: jsonType === 'json' ? 'application/json' : 'application/vnd.ipld.dag-json'
+ }
+ })
+ await handleJsonType(resp)
+ } catch (err: any) {
+ // TODO: simplify AbortErr handling to use err.name once https://github.com/libp2p/js-libp2p/pull/2446 is merged
+ if (err?.code === 'ABORT_ERR') {
+ return
+ }
+ if (err instanceof Error) {
+ setError(err.message)
+ }
+ }
+ }, [path, handleJsonType])
+
+ const onFetchImage = useCallback(async () => {
+ try {
+ controller?.abort() // abort any ongoing requests
+ setLoading('Fetching image response...')
+ const ctl = new AbortController()
+ setController(ctl)
+ const resp = await verifiedFetch(path, { signal: ctl.signal })
+ await handleImageType(resp)
+ } catch (err: any) {
+ if (err?.code === 'ABORT_ERR') {
+ return
+ }
+ // Don't render AbortErrors since they are user intiated
+ if (err instanceof Error) {
+ setError(err.message)
+ }
+ }
+ }, [path, handleImageType])
+
+ const onFetchFile = useCallback(async () => {
+ try {
+ controller?.abort() // abort any ongoing requests
+ setLoading('Fetching content to download...')
+ const ctl = new AbortController()
+ setController(ctl)
+ const resp = await verifiedFetch(path, { signal: ctl.signal })
+ const blob = await resp.blob()
+ const url = URL.createObjectURL(blob)
+ const downloadLink = document.createElement('a')
+ downloadLink.href = url
+ downloadLink.download = 'download'
+ setSuccess('') // clear output
+ downloadLink.click()
+ } catch (err: any) {
+ if (err?.code === 'ABORT_ERR') {
+ return
+ }
+ // Don't render AbortErrors since they are user intiated
+ if (err instanceof Error) {
+ setError(err.message)
+ }
+ }
+ }, [path])
+
+ const onAbort = useCallback(async () => {
+ if (controller != null) {
+ controller.abort('Rqeuest aborted')
+ setLoadingTo('')
+ }
+ }, [controller])
+
+ const onPathChange = useCallback(async (e: React.ChangeEvent) => {
+ setPath(e.target.value)
+ }, [])
+
+ const onFetchAuto = useCallback(async () => {
+ if (path == null) {
+ setError('Invalid path')
+ return
+ }
+ try {
+ controller?.abort() // abort any ongoing requests
+ setLoading('Fetching with automatic content detection...')
+ const ctl = new AbortController()
+ setController(ctl)
+ const resp = await verifiedFetch(path, { signal: ctl.signal })
+ const buffer = await resp.clone().arrayBuffer()
+ let contentType = (await fileTypeFromBuffer(new Uint8Array(buffer)))?.mime
+ if (!contentType) {
+ try {
+ // see if we can parse as json
+ await resp.clone().json()
+ contentType = 'application/json'
+ } catch (err) {
+ // ignore
+ }
+ }
+ switch (true) {
+ case contentType?.includes('image'):
+ await handleImageType(resp)
+ break
+ case contentType?.includes('json'):
+ await handleJsonType(resp)
+ break
+ case contentType?.includes('video'):
+ await handleVideoType(resp)
+ break
+ default:
+ setError(`Unknown content-type: ${contentType}`)
+ }
+ } catch (err: any) {
+ if (err?.code === 'ABORT_ERR') {
+ return
+ }
+ // Don't render AbortErrors since they are user intiated
+ if (err instanceof Error) {
+ setError(err.message)
+ }
+ }
+ }, [path, handleImageType, handleJsonType, handleVideoType])
+
+ return (
+
+
+
+ {/* Left ๐ */}
+
+
+
+
+
+
+
+
+
+
+
+
{helpText}
+
Source for example
+
@helia/verified-fetch
API Docs
+
+
+
+ {/* Right ๐ */}
+
+
+
+
+ )
+}
+
+export default App
diff --git a/examples/helia-browser-verified-fetch/src/Output.tsx b/examples/helia-browser-verified-fetch/src/Output.tsx
new file mode 100644
index 00000000..3898b816
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/src/Output.tsx
@@ -0,0 +1,63 @@
+interface OutputProps {
+ output: string | JSX.Element
+ loading?: string
+ err: string
+}
+
+export function Output ({ output, loading, err }: OutputProps): JSX.Element {
+ if (err.length > 0) {
+ return (
+
+ )
+ }
+
+ if(loading) {
+ return
+ }
+
+ if (typeof output === 'string') {
+ return (
+
+ {output.length > 0 && (
+
+ {`${output}`}
+
+ )}
+
+ )
+ }
+
+ return output
+}
+
+export function Loading ({ message }: { message: string }): JSX.Element {
+ return (
+
+ )
+}
+
+const Spinner = (): JSX.Element => (
+
+)
diff --git a/examples/helia-browser-verified-fetch/src/constants.ts b/examples/helia-browser-verified-fetch/src/constants.ts
new file mode 100644
index 00000000..798cf322
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/src/constants.ts
@@ -0,0 +1,26 @@
+export const helpText = `Example Paths
+=========
+IPNS name ๐
+ipns://k51qzi5uqu5dhp48cti0590jyvwgxssrii0zdf19pyfsxwoqomqvfg6bg8qj3s
+
+DNSLink ๐
+ipns://tokens.uniswap.org
+
+JSON ๐
+ipfs://bagaaieracglt4ey6qsxtvzqsgwnsw3b6p2tb7nmx5wdgxur2zia7q6nnzh7q
+
+DAG-CBOR ๐
+ipfs://bafyreicnokmhmrnlp2wjhyk2haep4tqxiptwfrp2rrs7rzq7uk766chqvq
+
+UnixFS JSON ๐
+ipfs://bafybeia5ci747h54m2ybc4rf6yqdtm6nzdisxv57pk66fgubjsnnja6wq4
+
+dag-json ๐
+ipfs://baguqeerasords4njcts6vs7qvdjfcvgnume4hqohf65zsfguprqphs3icwea
+
+UnixFS image ๐
+ipfs://bafybeicklkqcnlvtiscr2hzkubjwnwjinvskffn4xorqeduft3wq7vm5u4
+
+UnixFS video ๐
+ipfs://bafybeicq6y27fphdisjtxaybzxold7dczhvxiiyn3bvkyht7b36lveerrm
+`
diff --git a/examples/helia-browser-verified-fetch/src/main.tsx b/examples/helia-browser-verified-fetch/src/main.tsx
new file mode 100644
index 00000000..e63eef4a
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/src/main.tsx
@@ -0,0 +1,9 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.tsx'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/examples/helia-browser-verified-fetch/test/blockstore.js b/examples/helia-browser-verified-fetch/test/blockstore.js
new file mode 100644
index 00000000..863afdbe
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/test/blockstore.js
@@ -0,0 +1,16 @@
+import { MemoryBlockstore as OriginalMemoryBlockstore } from 'blockstore-core'
+import fixtures from './fixtures.js'
+
+/**
+ * Custom memory blockstore module which we pre-load with fixture blocks for testing
+ */
+export class MemoryBlockstore extends OriginalMemoryBlockstore {
+ constructor () {
+ super()
+
+ // prefill blockstore with test fixtures
+ Object.values(fixtures).forEach((fixture) => {
+ this.put(fixture.cid, fixture.data)
+ })
+ }
+}
diff --git a/examples/helia-browser-verified-fetch/test/fixtures.js b/examples/helia-browser-verified-fetch/test/fixtures.js
new file mode 100644
index 00000000..987afbe3
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/test/fixtures.js
@@ -0,0 +1,16 @@
+import { CID } from 'multiformats/cid'
+
+export default {
+ json: {
+ cid: CID.parse('bagaaierasords4njcts6vs7qvdjfcvgnume4hqohf65zsfguprqphs3icwea'),
+ data: Uint8Array.from([123, 34, 104, 101, 108, 108, 111, 34, 58, 34, 119, 111, 114, 108, 100, 34, 125])
+ },
+ dagJson: {
+ cid: CID.parse('baguqeerasords4njcts6vs7qvdjfcvgnume4hqohf65zsfguprqphs3icwea'),
+ data: Uint8Array.from([123, 34, 104, 101, 108, 108, 111, 34, 58, 34, 119, 111, 114, 108, 100, 34, 125])
+ },
+ dagCbor: {
+ cid: CID.parse('bafyreidykglsfhoixmivffc5uwhcgshx4j465xwqntbmu43nb2dzqwfvae'),
+ data: Uint8Array.from([161, 101, 104, 101, 108, 108, 111, 101, 119, 111, 114, 108, 100])
+ }
+}
diff --git a/examples/helia-browser-verified-fetch/test/index.spec.js b/examples/helia-browser-verified-fetch/test/index.spec.js
new file mode 100644
index 00000000..4bb9c936
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/test/index.spec.js
@@ -0,0 +1,64 @@
+import { setup, expect } from 'test-ipfs-example/browser'
+import fixtures from './fixtures.js'
+
+// Setup
+const test = setup()
+
+test.describe('Use @helia/verified-fetch With react and vite', () => {
+ // DOM
+ const ipfsPathInput = '#ipfs-path'
+ const fetchOutput = '#output'
+ const fetchAutoBtn = '#button-fetch-auto'
+
+ test.beforeEach(async ({ servers, page }) => {
+ await page.goto(servers[0].url)
+ })
+
+ test('should properly render ui with the ipfs path input and display JSON', async ({ page }) => {
+ // wait for helia node to be online
+ const ipfsPath = await page.locator(ipfsPathInput)
+ await expect(ipfsPath).toHaveClass(/bg-gray-50/)
+
+ await page.fill(ipfsPathInput, `ipfs://${fixtures.json.cid.toString()}`)
+ await page.click(fetchAutoBtn)
+ await page.locator(fetchOutput).waitFor('visible')
+
+ const output = await page.locator(fetchOutput)
+ await expect(output).toContainText(
+ '{ "hello": "world" }',
+ { timeout: 2000 }
+ )
+ })
+
+ test('should properly render ui with the ipfs path input and display DAG-JSON', async ({ page }) => {
+ // wait for helia node to be online
+ const ipfsPath = await page.locator(ipfsPathInput)
+ await expect(ipfsPath).toHaveClass(/bg-gray-50/)
+
+ await page.fill(ipfsPathInput, `ipfs://${fixtures.dagJson.cid.toString()}`)
+ await page.click(fetchAutoBtn)
+ await page.locator(fetchOutput).waitFor('visible')
+
+ const output = await page.locator(fetchOutput)
+ await expect(output).toContainText(
+ '{ "hello": "world" }',
+ { timeout: 2000 }
+ )
+ })
+
+ test('should properly render ui with the ipfs path input and display DAG-CBOR', async ({ page }) => {
+ // wait for helia node to be online
+ const ipfsPath = await page.locator(ipfsPathInput)
+ await expect(ipfsPath).toHaveClass(/bg-gray-50/)
+
+ await page.fill(ipfsPathInput, `ipfs://${fixtures.dagCbor.cid.toString()}`)
+ await page.click(fetchAutoBtn)
+ await page.locator(fetchOutput).waitFor('visible')
+
+ const output = await page.locator(fetchOutput)
+ await expect(output).toContainText(
+ '{ "hello": "world" }',
+ { timeout: 2000 }
+ )
+ })
+})
diff --git a/examples/helia-browser-verified-fetch/test/vite.config.js b/examples/helia-browser-verified-fetch/test/vite.config.js
new file mode 100644
index 00000000..60829f10
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/test/vite.config.js
@@ -0,0 +1,11 @@
+import { resolve } from 'path'
+import defaultConfig from '../vite.config.js'
+
+// override resolution of `blockstore-core` module so we can pre-fill a memory
+// blockstore with test data
+defaultConfig.resolve ??= {}
+defaultConfig.resolve.alias ??= {}
+defaultConfig.resolve.alias['blockstore-core/dist/src/memory.js'] = resolve(process.cwd(), 'test/blockstore.js')
+
+// https://vitejs.dev/config/
+export default defaultConfig
diff --git a/examples/helia-browser-verified-fetch/tsconfig.json b/examples/helia-browser-verified-fetch/tsconfig.json
new file mode 100644
index 00000000..6d545f54
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/examples/helia-browser-verified-fetch/vite.config.js b/examples/helia-browser-verified-fetch/vite.config.js
new file mode 100644
index 00000000..ba242447
--- /dev/null
+++ b/examples/helia-browser-verified-fetch/vite.config.js
@@ -0,0 +1,7 @@
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()]
+})
diff --git a/examples/helia-nestjs/tsconfig-check.aegir.json b/examples/helia-nestjs/tsconfig-check.aegir.json
new file mode 100644
index 00000000..1a02e18d
--- /dev/null
+++ b/examples/helia-nestjs/tsconfig-check.aegir.json
@@ -0,0 +1 @@
+{"compilerOptions":{"module":"ES2020","declaration":true,"removeComments":true,"emitDecoratorMetadata":true,"experimentalDecorators":true,"allowSyntheticDefaultImports":true,"target":"ES2020","sourceMap":true,"outDir":"./dist","baseUrl":"./","incremental":true,"skipLibCheck":true,"strictNullChecks":true,"noImplicitAny":false,"strictBindCallApply":false,"forceConsistentCasingInFileNames":false,"noFallthroughCasesInSwitch":false,"moduleResolution":"Node","noEmit":true,"emitDeclarationOnly":false}}