Skip to content

Commit d933208

Browse files
authoredFeb 27, 2024··
feat: create config page for sw settings (#24)
* 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
1 parent 333ee9f commit d933208

29 files changed

+977
-569
lines changed
 

‎package-lock.json

+257-347
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"serve": "webpack serve --mode=development",
1717
"serve:prod": "webpack serve --mode=production",
1818
"start": "npm run serve",
19+
"test": "npm run test:node",
1920
"test:node": "webpack --env test && npx mocha test-build/tests.js",
2021
"postinstall": "patch-package"
2122
},
@@ -35,10 +36,12 @@
3536
"@helia/http": "^1.0.0",
3637
"@helia/interface": "^4.0.0",
3738
"@helia/routers": "^1.0.0",
38-
"@helia/verified-fetch": "^0.0.0-3283a5c",
39+
"@helia/verified-fetch": "^0.0.0-28d62f7",
40+
"@libp2p/logger": "^4.0.6",
3941
"@sgtpooki/file-type": "^1.0.1",
4042
"blockstore-idb": "^1.1.8",
4143
"datastore-idb": "^2.1.8",
44+
"debug": "^4.3.4",
4245
"mime-types": "^2.1.35",
4346
"multiformats": "^11.0.2",
4447
"react": "^18.2.0",

‎src/app.css

+4
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,7 @@ form {
4141
flex: 1;
4242
word-break: break-word;
4343
}
44+
45+
.cursor-disabled {
46+
cursor: not-allowed;
47+
}

‎src/app.tsx

+18-67
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,28 @@
1-
import React, { useState, useEffect } from 'react'
2-
import CidRenderer from './components/CidRenderer'
3-
import Form from './components/Form.tsx'
4-
import Header from './components/Header.tsx'
5-
import { HeliaServiceWorkerCommsChannel } from './lib/channel.ts'
6-
import { ChannelActions, COLORS } from './lib/common.ts'
7-
import { getLocalStorageKey } from './lib/local-storage.ts'
8-
import type { OutputLine } from './components/types.ts'
9-
10-
const channel = new HeliaServiceWorkerCommsChannel('WINDOW')
1+
import React, { useContext } from 'react'
2+
import Config from './components/config.tsx'
3+
import { ConfigContext } from './context/config-context.tsx'
4+
import HelperUi from './helper-ui.tsx'
5+
import { isPathOrSubdomainRequest } from './lib/path-or-subdomain.ts'
6+
import RedirectPage from './redirectPage.tsx'
117

128
function App (): JSX.Element {
13-
const [, setOutput] = useState<OutputLine[]>([])
14-
const [requestPath, setRequestPath] = useState(localStorage.getItem(getLocalStorageKey('forms', 'requestPath')) ?? '')
15-
16-
useEffect(() => {
17-
localStorage.setItem(getLocalStorageKey('forms', 'requestPath'), requestPath)
18-
}, [requestPath])
19-
20-
const showStatus = (text: OutputLine['content'], color: OutputLine['color'] = COLORS.default, id: OutputLine['id'] = ''): void => {
21-
setOutput((prev: OutputLine[]) => {
22-
return [...prev,
23-
{
24-
content: text,
25-
color,
26-
id
27-
}
28-
]
29-
})
9+
const { isConfigExpanded, setConfigExpanded } = useContext(ConfigContext)
10+
if (window.location.pathname === '/config') {
11+
setConfigExpanded(true)
3012
}
31-
32-
const handleSubmit = async (e): Promise<void> => {
33-
e.preventDefault()
13+
if (window.location.pathname === '/config') {
14+
return <Config />
3415
}
3516

36-
useEffect(() => {
37-
const onMsg = (event): void => {
38-
const { data } = event
39-
// eslint-disable-next-line no-console
40-
console.log('received message:', data)
41-
switch (data.action) {
42-
case ChannelActions.SHOW_STATUS:
43-
if (data.data.text.trim() !== '') {
44-
showStatus(`${data.source}: ${data.data.text}`, data.data.color, data.data.id)
45-
} else {
46-
showStatus('', data.data.color, data.data.id)
47-
}
48-
break
49-
default:
50-
// eslint-disable-next-line no-console
51-
console.log(`SW action ${data.action} NOT_IMPLEMENTED yet...`)
52-
}
53-
}
54-
channel.onmessage(onMsg)
55-
}, [channel])
17+
if (isPathOrSubdomainRequest(window.location)) {
18+
return (<RedirectPage />)
19+
}
5620

21+
if (isConfigExpanded) {
22+
return (<Config />)
23+
}
5724
return (
58-
<>
59-
<Header />
60-
61-
<main className='pa4-l bg-snow mw7 mv5 center pa4'>
62-
<h1 className='pa0 f2 ma0 mb4 aqua tc'>Fetch content from IPFS using Helia in a SW</h1>
63-
<Form
64-
handleSubmit={handleSubmit}
65-
requestPath={requestPath}
66-
setRequestPath={setRequestPath}
67-
/>
68-
69-
<div className="bg-snow mw7 center w-100">
70-
<CidRenderer requestPath={requestPath} />
71-
</div>
72-
73-
</main>
74-
</>
25+
<HelperUi />
7526
)
7627
}
7728

‎src/components/CidRenderer.tsx

+8-9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
22
import { CID } from 'multiformats/cid'
3-
import React from 'react'
3+
import React, { useState } from 'react'
44

55
/**
66
* Test files:
@@ -82,12 +82,12 @@ function ValidationMessage ({ cid, requestPath, pathNamespacePrefix, children })
8282
}
8383

8484
export default function CidRenderer ({ requestPath }: { requestPath: string }): JSX.Element {
85-
const [contentType, setContentType] = React.useState<string | null>(null)
86-
const [isLoading, setIsLoading] = React.useState(false)
87-
const [abortController, setAbortController] = React.useState<AbortController | null>(null)
88-
const [blob, setBlob] = React.useState<Blob | null>(null)
89-
const [text, setText] = React.useState('')
90-
const [lastFetchPath, setLastFetchPath] = React.useState<string | null>(null)
85+
const [contentType, setContentType] = useState<string | null>(null)
86+
const [isLoading, setIsLoading] = useState(false)
87+
const [abortController, setAbortController] = useState<AbortController | null>(null)
88+
const [blob, setBlob] = useState<Blob | null>(null)
89+
const [text, setText] = useState('')
90+
const [lastFetchPath, setLastFetchPath] = useState<string | null>(null)
9191
/**
9292
* requestPath may be any of the following formats:
9393
*
@@ -106,8 +106,7 @@ export default function CidRenderer ({ requestPath }: { requestPath: string }):
106106
setAbortController(newAbortController)
107107
setLastFetchPath(swPath)
108108
setIsLoading(true)
109-
// eslint-disable-next-line no-console
110-
console.log(`fetching '${swPath}' from service worker`)
109+
111110
const res = await fetch(swPath, {
112111
signal: newAbortController.signal,
113112
method: 'GET',

‎src/components/Header.tsx

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1-
import React from 'react'
1+
import React, { useContext } from 'react'
2+
import { ConfigContext } from '../context/config-context.tsx'
3+
import gearIcon from '../gear-icon.svg'
24
import ipfsLogo from '../ipfs-logo.svg'
35

46
export default function Header (): JSX.Element {
7+
const { isConfigExpanded, setConfigExpanded } = useContext(ConfigContext)
8+
59
return (
610
<header className='flex items-center pa3 bg-navy bb bw3 b--aqua'>
711
<a href='https://ipfs.io' title='home'>
812
<img alt='IPFS logo' src={ipfsLogo} style={{ height: 50 }} className='v-top' />
913
</a>
14+
15+
<button onClick={() => { setConfigExpanded(!isConfigExpanded) }} style={{ border: 'none', position: 'absolute', top: '0.5rem', right: '0.5rem', background: 'none', cursor: 'pointer' }}>
16+
{/* https://isotropic.co/tool/hex-color-to-css-filter/ to #ffffff */}
17+
<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' />
18+
</button>
1019
</header>
1120
)
1221
}

‎src/components/collapsible.tsx

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import React, { useState } from 'react'
2+
3+
export interface CollapsibleProps {
4+
children: React.ReactNode
5+
collapsedLabel: string
6+
expandedLabel: string
7+
collapsed: boolean
8+
}
9+
10+
export function Collapsible ({ children, collapsedLabel, expandedLabel, collapsed }: CollapsibleProps): JSX.Element {
11+
const [cId] = useState(Math.random().toString(36).substring(7))
12+
const [isCollapsed, setCollapsed] = useState(collapsed)
13+
14+
return (
15+
<React.Fragment>
16+
<input type="checkbox" className="dn" name="collapsible" id={`collapsible-${cId}`} onClick={() => { setCollapsed(!isCollapsed) }} />
17+
<label htmlFor={`collapsible-${cId}`} className="collapsible__item-label db pv3 link black hover-blue pointer blue">{isCollapsed ? collapsedLabel : expandedLabel}</label>
18+
<div className={`bb b--black-20 ${isCollapsed ? 'dn' : ''}`}>
19+
{children}
20+
</div>
21+
</React.Fragment>
22+
)
23+
}

‎src/components/config.tsx

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React, { useCallback, useContext, useEffect, useState } from 'react'
2+
import { ConfigContext } from '../context/config-context.tsx'
3+
import { HeliaServiceWorkerCommsChannel } from '../lib/channel.ts'
4+
import { getConfig, loadConfigFromLocalStorage } from '../lib/config-db.ts'
5+
import { LOCAL_STORAGE_KEYS } from '../lib/local-storage.ts'
6+
import { Collapsible } from './collapsible'
7+
import LocalStorageInput from './local-storage-input.tsx'
8+
import { LocalStorageToggle } from './local-storage-toggle'
9+
import { ServiceWorkerReadyButton } from './sw-ready-button.tsx'
10+
11+
const channel = new HeliaServiceWorkerCommsChannel('WINDOW')
12+
13+
const urlValidationFn = (value: string): Error | null => {
14+
try {
15+
const urls = JSON.parse(value) satisfies string[]
16+
let i = 0
17+
try {
18+
urls.map((url, index) => {
19+
i = index
20+
return new URL(url)
21+
})
22+
} catch (e) {
23+
throw new Error(`URL "${urls[i]}" at index ${i} is not valid`)
24+
}
25+
return null
26+
} catch (err) {
27+
return err as Error
28+
}
29+
}
30+
31+
const stringValidationFn = (value: string): Error | null => {
32+
// we accept any string
33+
return null
34+
}
35+
36+
export default (): JSX.Element | null => {
37+
const { isConfigExpanded, setConfigExpanded } = useContext(ConfigContext)
38+
const [error, setError] = useState<Error | null>(null)
39+
40+
const isLoadedInIframe = window.self !== window.top
41+
42+
const postFromIframeToParentSw = useCallback(async () => {
43+
if (!isLoadedInIframe) {
44+
return
45+
}
46+
// we get the iframe origin from a query parameter called 'origin', if this is loaded in an iframe
47+
const targetOrigin = decodeURIComponent(window.location.search.split('origin=')[1])
48+
const config = await getConfig()
49+
50+
/**
51+
* The reload page in the parent window is listening for this message, and then it passes a RELOAD_CONFIG message to the service worker
52+
*/
53+
window.parent?.postMessage({ source: 'helia-sw-config-iframe', target: 'PARENT', action: 'RELOAD_CONFIG', config }, {
54+
targetOrigin
55+
})
56+
}, [])
57+
58+
useEffect(() => {
59+
/**
60+
* 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.
61+
*/
62+
void postFromIframeToParentSw()
63+
}, [])
64+
65+
const saveConfig = useCallback(async () => {
66+
try {
67+
await loadConfigFromLocalStorage()
68+
// update the BASE_URL service worker
69+
// TODO: use channel.messageAndWaitForResponse to ensure that the config is loaded before proceeding.
70+
channel.postMessage({ target: 'SW', action: 'RELOAD_CONFIG' })
71+
// update the <subdomain>.<namespace>.BASE_URL service worker
72+
await postFromIframeToParentSw()
73+
setConfigExpanded(false)
74+
} catch (err) {
75+
setError(err as Error)
76+
}
77+
}, [])
78+
79+
if (!isConfigExpanded) {
80+
return null
81+
}
82+
83+
return (
84+
<main className='pa4-l bg-snow mw7 center pa4'>
85+
<Collapsible collapsedLabel="View config" expandedLabel='Hide config' collapsed={true}>
86+
<LocalStorageInput localStorageKey={LOCAL_STORAGE_KEYS.config.gateways} label='Gateways' validationFn={urlValidationFn} defaultValue='[]' />
87+
<LocalStorageInput localStorageKey={LOCAL_STORAGE_KEYS.config.routers} label='Routers' validationFn={urlValidationFn} defaultValue='[]'/>
88+
<LocalStorageToggle localStorageKey={LOCAL_STORAGE_KEYS.config.autoReload} onLabel='Auto Reload' offLabel='Show Config' />
89+
<LocalStorageInput localStorageKey={LOCAL_STORAGE_KEYS.config.debug} label='Debug logging' validationFn={stringValidationFn} defaultValue=''/>
90+
<ServiceWorkerReadyButton id="save-config" label='Save Config' waitingLabel='Waiting for service worker registration...' onClick={() => { void saveConfig() }} />
91+
92+
{error != null && <span style={{ color: 'red' }}>{error.message}</span>}
93+
</Collapsible>
94+
</main>
95+
)
96+
}
+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import React, { useEffect, useState } from 'react'
2+
3+
export interface LocalStorageInputProps {
4+
localStorageKey: string
5+
label: string
6+
placeholder?: string
7+
defaultValue: string
8+
validationFn?(value: string): Error | null
9+
}
10+
11+
const defaultValidationFunction = (value: string): Error | null => {
12+
try {
13+
JSON.parse(value)
14+
return null
15+
} catch (err) {
16+
return err as Error
17+
}
18+
}
19+
export default ({ localStorageKey, label, placeholder, validationFn, defaultValue }: LocalStorageInputProps): JSX.Element => {
20+
const [value, setValue] = useState(localStorage.getItem(localStorageKey) ?? defaultValue)
21+
const [error, setError] = useState<null | Error>(null)
22+
23+
if (validationFn == null) {
24+
validationFn = defaultValidationFunction
25+
}
26+
27+
useEffect(() => {
28+
try {
29+
const err = validationFn?.(value)
30+
if (err != null) {
31+
throw err
32+
}
33+
localStorage.setItem(localStorageKey, value)
34+
setError(null)
35+
} catch (err) {
36+
setError(err as Error)
37+
}
38+
}, [value])
39+
40+
return (
41+
<>
42+
<label htmlFor={localStorageKey} className='f5 ma0 pb2 aqua fw4 db'>{label}:</label>
43+
<input
44+
className='input-reset bn black-80 bg-white pa3 w-100 mb3'
45+
id={localStorageKey}
46+
name={localStorageKey}
47+
type='text'
48+
placeholder={placeholder}
49+
value={value}
50+
onChange={(e) => { setValue(e.target.value) }}
51+
/>
52+
{error != null && <span style={{ color: 'red' }}>{error.message}</span>}
53+
</>
54+
)
55+
}

0 commit comments

Comments
 (0)
Please sign in to comment.