Skip to content

Commit

Permalink
feat: create config page for sw settings (#24)
Browse files Browse the repository at this point in the history
* feat: create config page for sw settings

* Update src/lib/channel.ts

* Update src/sw.ts

* Update src/lib/channel.ts

* Update src/index.tsx

* chore: fix build

* chore: change gear color

* feat: service worker config is shared to subdomains

* fix: test running

* fix: service worker registration

* chore: remove calls to removed to commsChannel methods

* chore: use LOCAL_STORAGE_KEYS

* feat: config page auto reload works

* chore: import react functions directly

* chore: use latest verified-fetch

* chore: remove console.logs and cleanup

* chore: consolidate app logic

* feat: users can control debugging output

* chore: todo determinism

* fix: gateway & routers default value

* fix: bug parsing ipfs namespaced subdomains

* chore: comment

* fix: use configured gateways & routers prior to defaults

* feat: config collapsed, reload button, sw-ready-btn

* chore: remove unused in config.tsx
  • Loading branch information
SgtPooki authored Feb 27, 2024
1 parent 333ee9f commit d933208
Show file tree
Hide file tree
Showing 29 changed files with 977 additions and 569 deletions.
604 changes: 257 additions & 347 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"serve": "webpack serve --mode=development",
"serve:prod": "webpack serve --mode=production",
"start": "npm run serve",
"test": "npm run test:node",
"test:node": "webpack --env test && npx mocha test-build/tests.js",
"postinstall": "patch-package"
},
Expand All @@ -35,10 +36,12 @@
"@helia/http": "^1.0.0",
"@helia/interface": "^4.0.0",
"@helia/routers": "^1.0.0",
"@helia/verified-fetch": "^0.0.0-3283a5c",
"@helia/verified-fetch": "^0.0.0-28d62f7",
"@libp2p/logger": "^4.0.6",
"@sgtpooki/file-type": "^1.0.1",
"blockstore-idb": "^1.1.8",
"datastore-idb": "^2.1.8",
"debug": "^4.3.4",
"mime-types": "^2.1.35",
"multiformats": "^11.0.2",
"react": "^18.2.0",
Expand Down
4 changes: 4 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ form {
flex: 1;
word-break: break-word;
}

.cursor-disabled {
cursor: not-allowed;
}
85 changes: 18 additions & 67 deletions src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,28 @@
import React, { useState, useEffect } from 'react'
import CidRenderer from './components/CidRenderer'
import Form from './components/Form.tsx'
import Header from './components/Header.tsx'
import { HeliaServiceWorkerCommsChannel } from './lib/channel.ts'
import { ChannelActions, COLORS } from './lib/common.ts'
import { getLocalStorageKey } from './lib/local-storage.ts'
import type { OutputLine } from './components/types.ts'

const channel = new HeliaServiceWorkerCommsChannel('WINDOW')
import React, { useContext } from 'react'
import Config from './components/config.tsx'
import { ConfigContext } from './context/config-context.tsx'
import HelperUi from './helper-ui.tsx'
import { isPathOrSubdomainRequest } from './lib/path-or-subdomain.ts'
import RedirectPage from './redirectPage.tsx'

function App (): JSX.Element {
const [, setOutput] = useState<OutputLine[]>([])
const [requestPath, setRequestPath] = useState(localStorage.getItem(getLocalStorageKey('forms', 'requestPath')) ?? '')

useEffect(() => {
localStorage.setItem(getLocalStorageKey('forms', 'requestPath'), requestPath)
}, [requestPath])

const showStatus = (text: OutputLine['content'], color: OutputLine['color'] = COLORS.default, id: OutputLine['id'] = ''): void => {
setOutput((prev: OutputLine[]) => {
return [...prev,
{
content: text,
color,
id
}
]
})
const { isConfigExpanded, setConfigExpanded } = useContext(ConfigContext)
if (window.location.pathname === '/config') {
setConfigExpanded(true)
}

const handleSubmit = async (e): Promise<void> => {
e.preventDefault()
if (window.location.pathname === '/config') {
return <Config />
}

useEffect(() => {
const onMsg = (event): void => {
const { data } = event
// eslint-disable-next-line no-console
console.log('received message:', data)
switch (data.action) {
case ChannelActions.SHOW_STATUS:
if (data.data.text.trim() !== '') {
showStatus(`${data.source}: ${data.data.text}`, data.data.color, data.data.id)
} else {
showStatus('', data.data.color, data.data.id)
}
break
default:
// eslint-disable-next-line no-console
console.log(`SW action ${data.action} NOT_IMPLEMENTED yet...`)
}
}
channel.onmessage(onMsg)
}, [channel])
if (isPathOrSubdomainRequest(window.location)) {
return (<RedirectPage />)
}

if (isConfigExpanded) {
return (<Config />)
}
return (
<>
<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 in a SW</h1>
<Form
handleSubmit={handleSubmit}
requestPath={requestPath}
setRequestPath={setRequestPath}
/>

<div className="bg-snow mw7 center w-100">
<CidRenderer requestPath={requestPath} />
</div>

</main>
</>
<HelperUi />
)
}

Expand Down
17 changes: 8 additions & 9 deletions src/components/CidRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
import { CID } from 'multiformats/cid'
import React from 'react'
import React, { useState } from 'react'

/**
* Test files:
Expand Down Expand Up @@ -82,12 +82,12 @@ function ValidationMessage ({ cid, requestPath, pathNamespacePrefix, children })
}

export default function CidRenderer ({ requestPath }: { requestPath: string }): JSX.Element {
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('')
const [lastFetchPath, setLastFetchPath] = React.useState<string | null>(null)
const [contentType, setContentType] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [abortController, setAbortController] = useState<AbortController | null>(null)
const [blob, setBlob] = useState<Blob | null>(null)
const [text, setText] = useState('')
const [lastFetchPath, setLastFetchPath] = useState<string | null>(null)
/**
* requestPath may be any of the following formats:
*
Expand All @@ -106,8 +106,7 @@ export default function CidRenderer ({ requestPath }: { requestPath: string }):
setAbortController(newAbortController)
setLastFetchPath(swPath)
setIsLoading(true)
// eslint-disable-next-line no-console
console.log(`fetching '${swPath}' from service worker`)

const res = await fetch(swPath, {
signal: newAbortController.signal,
method: 'GET',
Expand Down
11 changes: 10 additions & 1 deletion src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import React from 'react'
import React, { useContext } from 'react'
import { ConfigContext } from '../context/config-context.tsx'
import gearIcon from '../gear-icon.svg'
import ipfsLogo from '../ipfs-logo.svg'

export default function Header (): JSX.Element {
const { isConfigExpanded, setConfigExpanded } = useContext(ConfigContext)

return (
<header className='flex items-center pa3 bg-navy bb bw3 b--aqua'>
<a href='https://ipfs.io' title='home'>
<img alt='IPFS logo' src={ipfsLogo} style={{ height: 50 }} className='v-top' />
</a>

<button onClick={() => { setConfigExpanded(!isConfigExpanded) }} style={{ border: 'none', position: 'absolute', top: '0.5rem', right: '0.5rem', background: 'none', cursor: 'pointer' }}>
{/* https://isotropic.co/tool/hex-color-to-css-filter/ to #ffffff */}
<img alt='Config gear icon' src={gearIcon} style={{ height: 50, filter: 'invert(100%) sepia(100%) saturate(0%) hue-rotate(275deg) brightness(103%) contrast(103%)' }} className='v-top' />
</button>
</header>
)
}
23 changes: 23 additions & 0 deletions src/components/collapsible.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React, { useState } from 'react'

export interface CollapsibleProps {
children: React.ReactNode
collapsedLabel: string
expandedLabel: string
collapsed: boolean
}

export function Collapsible ({ children, collapsedLabel, expandedLabel, collapsed }: CollapsibleProps): JSX.Element {
const [cId] = useState(Math.random().toString(36).substring(7))
const [isCollapsed, setCollapsed] = useState(collapsed)

return (
<React.Fragment>
<input type="checkbox" className="dn" name="collapsible" id={`collapsible-${cId}`} onClick={() => { setCollapsed(!isCollapsed) }} />
<label htmlFor={`collapsible-${cId}`} className="collapsible__item-label db pv3 link black hover-blue pointer blue">{isCollapsed ? collapsedLabel : expandedLabel}</label>
<div className={`bb b--black-20 ${isCollapsed ? 'dn' : ''}`}>
{children}
</div>
</React.Fragment>
)
}
96 changes: 96 additions & 0 deletions src/components/config.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React, { useCallback, useContext, useEffect, useState } from 'react'
import { ConfigContext } from '../context/config-context.tsx'
import { HeliaServiceWorkerCommsChannel } from '../lib/channel.ts'
import { getConfig, loadConfigFromLocalStorage } from '../lib/config-db.ts'
import { LOCAL_STORAGE_KEYS } from '../lib/local-storage.ts'
import { Collapsible } from './collapsible'
import LocalStorageInput from './local-storage-input.tsx'
import { LocalStorageToggle } from './local-storage-toggle'
import { ServiceWorkerReadyButton } from './sw-ready-button.tsx'

const channel = new HeliaServiceWorkerCommsChannel('WINDOW')

const urlValidationFn = (value: string): Error | null => {
try {
const urls = JSON.parse(value) satisfies string[]
let i = 0
try {
urls.map((url, index) => {
i = index
return new URL(url)
})
} catch (e) {
throw new Error(`URL "${urls[i]}" at index ${i} is not valid`)
}
return null
} catch (err) {
return err as Error
}
}

const stringValidationFn = (value: string): Error | null => {
// we accept any string
return null
}

export default (): JSX.Element | null => {
const { isConfigExpanded, setConfigExpanded } = useContext(ConfigContext)
const [error, setError] = useState<Error | null>(null)

const isLoadedInIframe = window.self !== window.top

const postFromIframeToParentSw = useCallback(async () => {
if (!isLoadedInIframe) {
return
}
// we get the iframe origin from a query parameter called 'origin', if this is loaded in an iframe
const targetOrigin = decodeURIComponent(window.location.search.split('origin=')[1])
const config = await getConfig()

/**
* The reload page in the parent window is listening for this message, and then it passes a RELOAD_CONFIG message to the service worker
*/
window.parent?.postMessage({ source: 'helia-sw-config-iframe', target: 'PARENT', action: 'RELOAD_CONFIG', config }, {
targetOrigin
})
}, [])

useEffect(() => {
/**
* On initial load, we want to send the config to the parent window, so that the reload page can auto-reload if enabled, and the subdomain registered service worker gets the latest config without user interaction.
*/
void postFromIframeToParentSw()
}, [])

const saveConfig = useCallback(async () => {
try {
await loadConfigFromLocalStorage()
// update the BASE_URL service worker
// TODO: use channel.messageAndWaitForResponse to ensure that the config is loaded before proceeding.
channel.postMessage({ target: 'SW', action: 'RELOAD_CONFIG' })
// update the <subdomain>.<namespace>.BASE_URL service worker
await postFromIframeToParentSw()
setConfigExpanded(false)
} catch (err) {
setError(err as Error)
}
}, [])

if (!isConfigExpanded) {
return null
}

return (
<main className='pa4-l bg-snow mw7 center pa4'>
<Collapsible collapsedLabel="View config" expandedLabel='Hide config' collapsed={true}>
<LocalStorageInput localStorageKey={LOCAL_STORAGE_KEYS.config.gateways} label='Gateways' validationFn={urlValidationFn} defaultValue='[]' />
<LocalStorageInput localStorageKey={LOCAL_STORAGE_KEYS.config.routers} label='Routers' validationFn={urlValidationFn} defaultValue='[]'/>
<LocalStorageToggle localStorageKey={LOCAL_STORAGE_KEYS.config.autoReload} onLabel='Auto Reload' offLabel='Show Config' />
<LocalStorageInput localStorageKey={LOCAL_STORAGE_KEYS.config.debug} label='Debug logging' validationFn={stringValidationFn} defaultValue=''/>
<ServiceWorkerReadyButton id="save-config" label='Save Config' waitingLabel='Waiting for service worker registration...' onClick={() => { void saveConfig() }} />

{error != null && <span style={{ color: 'red' }}>{error.message}</span>}
</Collapsible>
</main>
)
}
55 changes: 55 additions & 0 deletions src/components/local-storage-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, { useEffect, useState } from 'react'

export interface LocalStorageInputProps {
localStorageKey: string
label: string
placeholder?: string
defaultValue: string
validationFn?(value: string): Error | null
}

const defaultValidationFunction = (value: string): Error | null => {
try {
JSON.parse(value)
return null
} catch (err) {
return err as Error
}
}
export default ({ localStorageKey, label, placeholder, validationFn, defaultValue }: LocalStorageInputProps): JSX.Element => {
const [value, setValue] = useState(localStorage.getItem(localStorageKey) ?? defaultValue)
const [error, setError] = useState<null | Error>(null)

if (validationFn == null) {
validationFn = defaultValidationFunction
}

useEffect(() => {
try {
const err = validationFn?.(value)
if (err != null) {
throw err
}
localStorage.setItem(localStorageKey, value)
setError(null)
} catch (err) {
setError(err as Error)
}
}, [value])

return (
<>
<label htmlFor={localStorageKey} className='f5 ma0 pb2 aqua fw4 db'>{label}:</label>
<input
className='input-reset bn black-80 bg-white pa3 w-100 mb3'
id={localStorageKey}
name={localStorageKey}
type='text'
placeholder={placeholder}
value={value}
onChange={(e) => { setValue(e.target.value) }}
/>
{error != null && <span style={{ color: 'red' }}>{error.message}</span>}
</>
)
}
Loading

0 comments on commit d933208

Please sign in to comment.