Skip to content

Commit

Permalink
feat: rendered CID in browser with content-type recognition
Browse files Browse the repository at this point in the history
  • Loading branch information
SgtPooki committed Mar 30, 2023
1 parent 7ed6855 commit 0c9d304
Show file tree
Hide file tree
Showing 6 changed files with 292 additions and 109 deletions.
16 changes: 12 additions & 4 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import TerminalOutput from './components/TerminalOutput.tsx'
import type { OutputLine } from './components/types.ts'
import Header from './components/Header.tsx'
import type { Libp2pConfigTypes } from './types.ts'
import Video from './components/Video.tsx'
import CidRenderer from './components/CidRenderer'

const channel = new HeliaServiceWorkerCommsChannel('WINDOW')

Expand Down Expand Up @@ -99,7 +101,7 @@ function App (): JSX.Element {
<Header />

<main className='pa4-l bg-snow mw7 mv5 center pa4'>
<h1 className='pa0 f2 ma0 mb4 aqua tc'>Fetch content from IPFS using Helia</h1>
<h1 className='pa0 f2 ma0 mb4 aqua tc'>Fetch content from IPFS using Helia in a SW</h1>
<Form
handleSubmit={handleSubmit}
fileCid={fileCid}
Expand All @@ -111,16 +113,22 @@ function App (): JSX.Element {
configType={configType}
setConfigType={setConfigType}
/>
<a href={`/ipfs/${fileCid}`} target="_blank">
{/* <a href={`/ipfs/${fileCid}`} target="_blank">
<button className='button-reset pv3 tc bn bg-animate bg-black-80 hover-bg-aqua white pointer w-100'>Test Gateway Fallback</button>
</a>
</a> */}

<h3>Output</h3>
{/* <h3>Output</h3>
<div className='window'>
<div className='header' />
<TerminalOutput output={output} terminalRef={terminalRef} />
</div> */}
<div className="bg-snow mw7 mv5 center w-100">
<CidRenderer cid={fileCid} />
</div>
{/* <div className="pa4-l bg-snow mw7 mv5 center pa4">
<Video cid={fileCid} />
</div> */}
</main>
</>
)
Expand Down
110 changes: 110 additions & 0 deletions src/components/CidRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
import React, { useEffect } from 'react'

import { CID } from 'multiformats/cid'
// import Video from './Video'

/**
* Test files:
* bafkreienxxjqg3jomg5b75k7547dgf7qlbd3qpxy2kbg537ck3rol4mcve - text - https://bafkreienxxjqg3jomg5b75k7547dgf7qlbd3qpxy2kbg537ck3rol4mcve.ipfs.w3s.link/?filename=test.txt
* bafkreicafxt3zr4cshf7qteztjzl62ouxqrofu647e44wt7s2iaqjn7bra - image/jpeg - http://127.0.0.1:8080/ipfs/bafkreicafxt3zr4cshf7qteztjzl62ouxqrofu647e44wt7s2iaqjn7bra?filename=bafkreicafxt3zr4cshf7qteztjzl62ouxqrofu647e44wt7s2iaqjn7bra
* bafkreif4ufrfpfcmqn5ltjvmeisgv4k7ykqz2mjygpngtwt4bijxosidqa - image/svg+xml - https://bafkreif4ufrfpfcmqn5ltjvmeisgv4k7ykqz2mjygpngtwt4bijxosidqa.ipfs.dweb.link/?filename=Web3.Storage-logo.svg
* bafybeiekildl23opzqcsufewlbadhbabs6pyqg35tzpfavgtjyhchyikxa - video/quicktime - https://bafybeiekildl23opzqcsufewlbadhbabs6pyqg35tzpfavgtjyhchyikxa.ipfs.dweb.link
* bafkreiezuss4xkt5gu256vjccx7vocoksxk77vwmdrpwoumfbbxcy2zowq - video/webm (147.78 KiB) - https://bafkreiezuss4xkt5gu256vjccx7vocoksxk77vwmdrpwoumfbbxcy2zowq.ipfs.dweb.link
* bafybeierkpfmf4vhtdiujgahfptiyriykoetxf3rmd3vcxsdemincpjoyu - video/mp4 (2.80 MiB) - https://bafybeierkpfmf4vhtdiujgahfptiyriykoetxf3rmd3vcxsdemincpjoyu.ipfs.dweb.link
* QmbGtJg23skhvFmu9mJiePVByhfzu5rwo74MEkVDYAmF5T - video (160MiB)
*/

/**
*
* Test CIDs
* QmbGtJg23skhvFmu9mJiePVByhfzu5rwo74MEkVDYAmF5T
*
*/

export default function CidRenderer ({ cid }: { cid: string }): JSX.Element {
// const [isVideo, setIsVideo] = React.useState(false)
// const [isImage, setIsImage] = React.useState(false)
const [contentType, setContentType] = React.useState<string | null>(null)
const [isLoading, setIsLoading] = React.useState(false)
const [abortController, setAbortController] = React.useState<AbortController | null>(null)
const [blob, setBlob] = React.useState<Blob | null>(null)
const [text, setText] = React.useState('')

useEffect(() => {
if (cid === null || cid === '' || isLoading) {
return
}
try {
CID.parse(cid)
} catch {
return
}
// cancel previous fetchRequest when cid is changed
abortController?.abort()
// set loading to bloack this useEffect from running until done.
setIsLoading(true)
const newAbortController = new AbortController()
setAbortController(newAbortController)
// console.log('fetching from CidRenderer')
const fetchContent = async (): Promise<void> => {
const res = await fetch(`ipfs/${cid}`, {
signal: newAbortController.signal,
method: 'GET',
headers: {
// cache: 'no-cache', // uncomment when testing
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8'
}
})
const contentType = res.headers.get('content-type')
// console.log('res.headers: ', res.headers)
// console.log('contentType: ', contentType)
setContentType(contentType)
console.log('contentType: ', contentType)
// if ((contentType?.startsWith('video/')) === true) {
// // setIsVideo(true)
// } else if ((contentType?.startsWith('image/')) === true) {
// // setIsImage(true)
// }
setBlob(await res.clone().blob())
setText(await res.text())
setIsLoading(false)
}

void fetchContent()
}, [cid])

// console.log('cid: ', cid)
if (cid == null || cid === '') {
return <span>Nothing to render yet. Enter a CID</span> // bafkreiezuss4xkt5gu256vjccx7vocoksxk77vwmdrpwoumfbbxcy2zowq
}
try {
CID.parse(cid)
} catch {
return <span>Invalid CID</span>
}

if (isLoading) {
return <span>Loading...</span>
}

if (contentType?.startsWith('video/') && blob != null) {
return (
<video controls autoPlay loop className="center" width="100%">
<source src={URL.createObjectURL(blob)} type={contentType} />
</video>
)
}
if (contentType?.startsWith('image/') && blob != null) {
return <img src={URL.createObjectURL(blob)} />
}
if (contentType?.startsWith('text/') && blob != null) {
return <pre>{text}</pre>
}
return <span>Not a supported content-type of <pre>{contentType}</pre></span>
// return (
// <video controls autoPlay loop className="center">
// <source src={`/ipfs/${cid}`} type="video/mp4" />
// </video>
// )
}
12 changes: 6 additions & 6 deletions src/components/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import Libp2pConfigTypes from './Libp2pConfigTypes'

export default ({ handleSubmit, fileCid, setFileCid, localMultiaddr, setLocalMultiaddr, useServiceWorker, setUseServiceWorker, configType, setConfigType }): JSX.Element => (
<form id='add-file' onSubmit={handleSubmit}>
<label htmlFor='local-multiaddr' className='f5 ma0 pb2 aqua fw4 db'>Local multiaddr (e.g. webtransport multiaddr for your kubo node)</label>
{/* <label htmlFor='local-multiaddr' className='f5 ma0 pb2 aqua fw4 db'>Local multiaddr (e.g. webtransport multiaddr for your kubo node)</label>
<input
className='input-reset bn black-80 bg-white pa3 w-100 mb3'
id='local-multiaddr'
name='local-multiaddr'
type='text'
placeholder='/ip4/127.0.0.1/udp/4001/quic-v1/webtransport/certhash/XXXXXX/certhash/XXXXXX/p2p/YourLocalKuboPeerId'
value={localMultiaddr} onChange={(e) => setLocalMultiaddr(e.target.value)}
/>
/> */}
<label htmlFor='file-name' className='f5 ma0 pb2 aqua fw4 db'>CID</label>
<input
className='input-reset bn black-80 bg-white pa3 w-100 mb3'
Expand All @@ -22,7 +22,7 @@ export default ({ handleSubmit, fileCid, setFileCid, localMultiaddr, setLocalMul
required
value={fileCid} onChange={(e) => setFileCid(e.target.value)}
/>
<label htmlFor='useServiceWorker' className='f5 ma0 pb2 aqua fw4 db'>Use Service Worker
{/* <label htmlFor='useServiceWorker' className='f5 ma0 pb2 aqua fw4 db'>Use Service Worker
<input
className='ml2'
id='useServiceWorker'
Expand All @@ -31,14 +31,14 @@ export default ({ handleSubmit, fileCid, setFileCid, localMultiaddr, setLocalMul
checked={useServiceWorker} onChange={(e) => setUseServiceWorker(e.target.checked)}
/>
</label>
<Libp2pConfigTypes configType={configType} setConfigType={setConfigType}/>
<Libp2pConfigTypes configType={configType} setConfigType={setConfigType}/> */}

<button
{/* <button
className='button-reset pv3 tc bn bg-animate bg-black-80 hover-bg-aqua white pointer w-100'
id='add-submit'
type='submit'
>
Fetch in page
</button>
</button> */}
</form>
)
9 changes: 9 additions & 0 deletions src/components/Video.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from 'react'

export default function Video ({ cid }: { cid: string }): JSX.Element {
return (
<video controls autoPlay loop className="center">
<source src={`/ipfs/${cid}`} type="video/mp4" />
</video>
)
}
143 changes: 143 additions & 0 deletions src/lib/heliaFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { Helia } from '@helia/interface'
import { unixfs } from '@helia/unixfs'
import { CID } from 'multiformats/cid'
import FileType from 'file-type/core'

// import { getHelia } from '../get-helia.ts'

export interface HeliaFetchOptions {
path: string
helia: Helia
}

function mergeUint8Arrays (a: Uint8Array, b: Uint8Array): Uint8Array {
const c = new Uint8Array(a.length + b.length)
c.set(a, 0)
c.set(b, a.length)
return c
}

/**
* Test files:
* bafkreienxxjqg3jomg5b75k7547dgf7qlbd3qpxy2kbg537ck3rol4mcve - text - https://bafkreienxxjqg3jomg5b75k7547dgf7qlbd3qpxy2kbg537ck3rol4mcve.ipfs.w3s.link/?filename=test.txt
* bafkreicafxt3zr4cshf7qteztjzl62ouxqrofu647e44wt7s2iaqjn7bra - image/jpeg - http://127.0.0.1:8080/ipfs/bafkreicafxt3zr4cshf7qteztjzl62ouxqrofu647e44wt7s2iaqjn7bra?filename=bafkreicafxt3zr4cshf7qteztjzl62ouxqrofu647e44wt7s2iaqjn7bra
* QmY7fzZEpgDUqZ7BEePSS5JxxezDj3Zy36EEpWSmKmv5mo - image/jpeg - http://127.0.0.1:8080/ipfs/QmY7fzZEpgDUqZ7BEePSS5JxxezDj3Zy36EEpWSmKmv5mo?filename=QmY7fzZEpgDUqZ7BEePSS5JxxezDj3Zy36EEpWSmKmv5mo
* bafkreif4ufrfpfcmqn5ltjvmeisgv4k7ykqz2mjygpngtwt4bijxosidqa - image/svg+xml - https://bafkreif4ufrfpfcmqn5ltjvmeisgv4k7ykqz2mjygpngtwt4bijxosidqa.ipfs.dweb.link/?filename=Web3.Storage-logo.svg
* bafybeiekildl23opzqcsufewlbadhbabs6pyqg35tzpfavgtjyhchyikxa - video/quicktime - https://bafybeiekildl23opzqcsufewlbadhbabs6pyqg35tzpfavgtjyhchyikxa.ipfs.dweb.link
* bafkreiezuss4xkt5gu256vjccx7vocoksxk77vwmdrpwoumfbbxcy2zowq - video/webm (147.78 KiB) - https://bafkreiezuss4xkt5gu256vjccx7vocoksxk77vwmdrpwoumfbbxcy2zowq.ipfs.dweb.link
* bafybeierkpfmf4vhtdiujgahfptiyriykoetxf3rmd3vcxsdemincpjoyu - video/mp4 (2.80 MiB) - https://bafybeierkpfmf4vhtdiujgahfptiyriykoetxf3rmd3vcxsdemincpjoyu.ipfs.dweb.link
*/

function isSvgText (bytes: Uint8Array): boolean {
const svgText = new TextDecoder().decode(bytes.slice(0, 4))
return svgText.startsWith('<svg')
}

function handleVideoMimeTypes (videoMimeType: string): string {
// console.log('videoMimeType: ', videoMimeType)
// return 'video/webm;codecs=h264'
switch (videoMimeType) {
// case 'video/mp4':
// return 'video/webm;codecs=h264'
case 'video/quicktime':
return 'video/mp4'
default:
return videoMimeType
}
}

/**
* TODO: support video files (v0=playable, v1=seekable and navigable)
* TODO: support audio files
*
* @param param0
*
* For inspiration
* @see https://github.com/ipfs/js-ipfs/blob/master/packages/ipfs-http-response/src/utils/content-type.js
* @see https://github.com/RangerMauve/js-ipfs-fetch
* @returns
*/
async function getContentType ({ cid, bytes }: { cid?: unknown, bytes: Uint8Array }): Promise<string> {
// const fileType = magicBytesFiletype(bytes)
// console.log('magicBytesFiletype(bytes): ', magicBytesFiletype(bytes))
const fileTypeDep = await FileType.fromBuffer(bytes)
if (typeof fileTypeDep !== 'undefined') {
// console.log('fileTypeDep.mime: ', fileTypeDep.mime)
return handleVideoMimeTypes(fileTypeDep.mime)
}

if (isSvgText(bytes)) {
return 'image/svg+xml'
}

return 'text/plain'
}

/**
* * TODO: implement as much of the gateway spec as possible.
* * TODO: why we would be better than ipfs.io/other-gateway
* * TODO: have error handling that renders 404/500/other if the request is bad.
*
* @param event
* @returns
*/
export async function heliaFetch ({ path, helia }: HeliaFetchOptions): Promise<Response> {
const pathParts = path.split('/')
// console.log('pathParts: ', pathParts)
// const scheme = pathParts[1]
// console.log('scheme: ', scheme)
const cidString = pathParts[2]
// console.log('cidString: ', cidString)
// const helia = await getHelia({ libp2pConfigType: 'ipni' })

const fs = unixfs(helia)
const cid = CID.parse(cidString)

const asyncIt = fs.cat(cid)

const iter = asyncIt[Symbol.asyncIterator]()

let next = iter.next
next = next.bind(iter)

// we have to get the first chunk before responding or else the response has the incorrect content type
const firstChunk = await next()

const fileType: string = await getContentType({ bytes: firstChunk.value })
// if (typeof chunkFileType !== 'undefined') {
// fileType = handleVideoMimeTypes(chunkFileType.mime)
// }

// let bytes: Uint8Array = new Uint8Array()
// let chunkFiletype: FileType.FileTypeResult | undefined
const readableStream = new ReadableStream({
async start (controller) {
controller.enqueue(firstChunk.value)
// if (fileType == null) {
// }

if (firstChunk.done === true) {
controller.close()
return
}
for (let { value, done } = await next(); done === false; { value, done } = await next()) {
// for await (const chunk of fs.cat(cid)) {
const chunk = value
// console.log('chunk: ', chunk)
controller.enqueue(chunk)
}
// console.log('final fileType: ', fileType)
controller.close()
}
})

// need to return byte stream
return new Response(readableStream, {
headers: {
'Cache-Control': 'public, max-age=29030400, immutable', // same as ipfs.io gateway
// 'Cache-Control': 'no-cache', // disable caching when debugging
'Content-Type': fileType ?? 'text/plain'
}

})
}
Loading

0 comments on commit 0c9d304

Please sign in to comment.