Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create config page for sw settings #24

Merged
merged 29 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b29048f
feat: create config page for sw settings
SgtPooki Feb 21, 2024
53001c6
Update src/lib/channel.ts
SgtPooki Feb 21, 2024
7c6410f
Update src/sw.ts
SgtPooki Feb 21, 2024
eca1114
Update src/lib/channel.ts
SgtPooki Feb 21, 2024
0d5f91f
Update src/index.tsx
SgtPooki Feb 21, 2024
764ebbc
chore: fix build
SgtPooki Feb 21, 2024
527cc4b
Merge branch 'main' into feat/config-page
SgtPooki Feb 22, 2024
f80b2ed
chore: change gear color
SgtPooki Feb 22, 2024
7c988ad
feat: service worker config is shared to subdomains
SgtPooki Feb 22, 2024
3e73d05
fix: test running
SgtPooki Feb 22, 2024
2c0cd88
fix: service worker registration
SgtPooki Feb 22, 2024
658c41d
chore: remove calls to removed to commsChannel methods
SgtPooki Feb 22, 2024
535fc7e
chore: use LOCAL_STORAGE_KEYS
SgtPooki Feb 22, 2024
c588d2a
feat: config page auto reload works
SgtPooki Feb 22, 2024
d493df7
chore: import react functions directly
SgtPooki Feb 22, 2024
f50182d
chore: use latest verified-fetch
SgtPooki Feb 22, 2024
732f9a4
chore: remove console.logs and cleanup
SgtPooki Feb 23, 2024
ff7bafe
chore: consolidate app logic
SgtPooki Feb 23, 2024
b85f323
feat: users can control debugging output
SgtPooki Feb 23, 2024
8068f14
chore: todo determinism
SgtPooki Feb 23, 2024
ffe08c1
fix: gateway & routers default value
SgtPooki Feb 23, 2024
d078e2d
fix: bug parsing ipfs namespaced subdomains
SgtPooki Feb 23, 2024
9304b9f
chore: comment
SgtPooki Feb 23, 2024
78ebfc6
fix: use configured gateways & routers prior to defaults
SgtPooki Feb 23, 2024
56485a5
Merge branch 'main' into feat/config-page
SgtPooki Feb 23, 2024
d4aa1d0
Merge branch 'main' into feat/config-page
SgtPooki Feb 24, 2024
78ec8a3
Merge branch 'main' into feat/config-page
SgtPooki Feb 27, 2024
21c2e7b
feat: config collapsed, reload button, sw-ready-btn
SgtPooki Feb 27, 2024
4fcc269
chore: remove unused in config.tsx
SgtPooki Feb 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 />)
}
Comment on lines +21 to +22
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eventually, this landing page should only be the config page....?

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>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vscode was being weird in this file yelling about all types of syntax errors that didn't exist after closing it and re-opening. ¯\_(ツ)_/¯

<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
Loading