-
Notifications
You must be signed in to change notification settings - Fork 17
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
Changes from all commits
b29048f
53001c6
7c6410f
eca1114
0d5f91f
764ebbc
527cc4b
f80b2ed
7c988ad
3e73d05
2c0cd88
658c41d
535fc7e
c588d2a
d493df7
f50182d
732f9a4
ff7bafe
b85f323
8068f14
ffe08c1
d078e2d
9304b9f
78ebfc6
56485a5
d4aa1d0
78ec8a3
21c2e7b
4fcc269
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -41,3 +41,7 @@ form { | |
flex: 1; | ||
word-break: break-word; | ||
} | ||
|
||
.cursor-disabled { | ||
cursor: not-allowed; | ||
} |
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> | ||
) | ||
} |
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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
) | ||
} |
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> | ||
) | ||
} |
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>} | ||
</> | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
/** | ||
Inspiration from https://dev.to/codebubb/create-a-simple-on-off-slide-toggle-with-css-db8 | ||
*/ | ||
|
||
.local-storage-toggle input.status { | ||
display: none; | ||
} | ||
/* .local-storage-toggle input.status + label { | ||
height: 100%; | ||
width: 100%; | ||
} */ | ||
.local-storage-toggle input.status + label > .status-switch { | ||
cursor: pointer; | ||
/* width: 100%; */ | ||
/* height: 100%; */ | ||
position: relative; | ||
background-color: grey; | ||
color: white; | ||
transition: all 0.5s ease; | ||
padding: 3px; | ||
border-radius: 3px; | ||
} | ||
.local-storage-toggle input.status + label > .status-switch:before, .local-storage-toggle input.status + label > .status-switch:after { | ||
border-radius: 2px; | ||
height: calc(100% - 6px); | ||
width: calc(50% - 3px); | ||
display: flex; | ||
align-items: center; | ||
position: absolute; | ||
justify-content: center; | ||
transition: all 0.3s ease; | ||
} | ||
.local-storage-toggle input.status + label > .status-switch:before { | ||
background-color: white; | ||
color: black; | ||
box-shadow: 0 0 4px 4px rgba(0, 0, 0, 0.2); | ||
left: 3px; | ||
z-index: 10; | ||
content: attr(data-unchecked); | ||
} | ||
.local-storage-toggle input.status + label > .status-switch:after { | ||
right: 0; | ||
content: attr(data-checked); | ||
/* add strikethrough to show to the user that it's disabled */ | ||
text-decoration: line-through; | ||
} | ||
.local-storage-toggle input.status:checked + label > .status-switch { | ||
background-color: #40c253; | ||
} | ||
.local-storage-toggle input.status:checked + label > .status-switch:after { | ||
left: 0; | ||
content: attr(data-unchecked); | ||
/* add strikethrough to show to the user that it's disabled */ | ||
text-decoration: line-through; | ||
} | ||
.local-storage-toggle input.status:checked + label > .status-switch:before { | ||
color: #40c253; | ||
left: 50%; | ||
content: attr(data-checked); | ||
} | ||
|
||
.h3-custom { | ||
height: 3rem; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
/** | ||
Inspiration from https://dev.to/codebubb/create-a-simple-on-off-slide-toggle-with-css-db8 | ||
*/ | ||
import React, { useState } from 'react' | ||
import './local-storage-toggle.css' | ||
|
||
interface LocalStorageToggleProps { | ||
localStorageKey: string | ||
offLabel: string | ||
onLabel: string | ||
} | ||
|
||
export const LocalStorageToggle: React.FC<LocalStorageToggleProps> = ({ localStorageKey, onLabel = 'Off', offLabel = 'On' }) => { | ||
const [isChecked, setIsChecked] = useState(() => { | ||
const savedValue = localStorage.getItem(localStorageKey) | ||
return savedValue === 'true' | ||
}) | ||
|
||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => { | ||
const newValue = event.target.checked | ||
setIsChecked(newValue) | ||
localStorage.setItem(localStorageKey, String(newValue)) | ||
} | ||
|
||
return ( | ||
<> | ||
<div className="local-storage-toggle input-reset bn black-80 w-100 mb3"> | ||
<input className="status" style={{ display: 'none' }} id={localStorageKey} type="checkbox" name="status" checked={isChecked} onChange={handleChange} /> | ||
<label htmlFor={localStorageKey} className="w-100 h-100"> | ||
<div className="status-switch relative h3-custom pointer white bg-gray w-100" data-unchecked={offLabel} data-checked={onLabel} /> | ||
</label> | ||
</div> | ||
</> | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import React, { useContext, useMemo } from 'react' | ||
import { ServiceWorkerContext } from '../context/service-worker-context.tsx' | ||
type ButtonProps = JSX.IntrinsicElements['button'] | ||
|
||
interface ServiceWorkerReadyButtonProps extends ButtonProps { | ||
label: string | ||
waitingLabel?: string | ||
} | ||
|
||
export const ServiceWorkerReadyButton = ({ className, label, waitingLabel, ...props }: ServiceWorkerReadyButtonProps): JSX.Element => { | ||
const { isServiceWorkerRegistered } = useContext(ServiceWorkerContext) | ||
|
||
const buttonClasses = new Set(['button-reset', 'pv3', 'tc', 'bn', 'white', 'w-100', 'cursor-disabled', 'bg-gray']) | ||
if (isServiceWorkerRegistered) { | ||
buttonClasses.delete('bg-gray') | ||
buttonClasses.delete('cursor-disabled') | ||
buttonClasses.add('bg-animate') | ||
buttonClasses.add('bg-black-80') | ||
buttonClasses.add('hover-bg-aqua') | ||
buttonClasses.add('pointer') | ||
} | ||
Comment on lines
+13
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this should probably be a useMemo and two distinct string values |
||
|
||
const lbl = useMemo(() => { | ||
if (!isServiceWorkerRegistered) { | ||
return waitingLabel ?? label | ||
} | ||
return label | ||
}, [isServiceWorkerRegistered, waitingLabel, label]) | ||
|
||
return ( | ||
<button | ||
disabled={!isServiceWorkerRegistered} | ||
className={`${Array.from(buttonClasses).join(' ')} ${className}`} | ||
{...props} | ||
> | ||
{lbl} | ||
</button> | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import React, { createContext, useState } from 'react' | ||
|
||
const isLoadedInIframe = window.self !== window.top | ||
|
||
export const ConfigContext = createContext({ | ||
isConfigExpanded: isLoadedInIframe, | ||
setConfigExpanded: (value: boolean) => {} | ||
}) | ||
|
||
export const ConfigProvider = ({ children, expanded = isLoadedInIframe }: { children: JSX.Element[] | JSX.Element, expanded?: boolean }): JSX.Element => { | ||
const [isConfigExpanded, setConfigExpanded] = useState(expanded) | ||
const isExplicitlyLoadedConfigPage = window.location.pathname === '/config' | ||
|
||
const setConfigExpandedWrapped = (value: boolean): void => { | ||
if (isLoadedInIframe || isExplicitlyLoadedConfigPage) { | ||
// ignore it | ||
} else { | ||
setConfigExpanded(value) | ||
} | ||
} | ||
|
||
return ( | ||
<ConfigContext.Provider value={{ isConfigExpanded, setConfigExpanded: setConfigExpandedWrapped }}> | ||
{children} | ||
</ConfigContext.Provider> | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import React, { createContext, useEffect, useState } from 'react' | ||
import { error } from '../lib/logger.ts' | ||
import { registerServiceWorker } from '../service-worker-utils.ts' | ||
|
||
export const ServiceWorkerContext = createContext({ | ||
isServiceWorkerRegistered: false | ||
}) | ||
|
||
export const ServiceWorkerProvider = ({ children }): JSX.Element => { | ||
const [isServiceWorkerRegistered, setIsServiceWorkerRegistered] = useState(false) | ||
|
||
useEffect(() => { | ||
if (isServiceWorkerRegistered) { | ||
return | ||
} | ||
async function doWork (): Promise<void> { | ||
const registration = await navigator.serviceWorker.getRegistration() | ||
|
||
if (registration != null) { | ||
2color marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// service worker already registered | ||
// attempt to update it | ||
await registration.update() | ||
setIsServiceWorkerRegistered(true) | ||
} else { | ||
try { | ||
const registration = await registerServiceWorker() | ||
await registration.update() | ||
setIsServiceWorkerRegistered(true) | ||
} catch (err) { | ||
error('error registering service worker', err) | ||
} | ||
} | ||
} | ||
void doWork() | ||
2color marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}, []) | ||
|
||
return ( | ||
<ServiceWorkerContext.Provider value={{ isServiceWorkerRegistered }}> | ||
{children} | ||
</ServiceWorkerContext.Provider> | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
import React, { useState, useEffect } from 'react' | ||
import CidRenderer from './components/CidRenderer.tsx' | ||
import Form from './components/Form.tsx' | ||
import Header from './components/Header.tsx' | ||
import { LOCAL_STORAGE_KEYS } from './lib/local-storage.ts' | ||
|
||
export default function (): JSX.Element { | ||
const [requestPath, setRequestPath] = useState(localStorage.getItem(LOCAL_STORAGE_KEYS.forms.requestPath) ?? '') | ||
|
||
useEffect(() => { | ||
localStorage.setItem(LOCAL_STORAGE_KEYS.forms.requestPath, requestPath) | ||
}, [requestPath]) | ||
|
||
const handleSubmit = async (e): Promise<void> => { | ||
e.preventDefault() | ||
} | ||
|
||
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> | ||
</> | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,38 +2,23 @@ import React from 'react' | |
import ReactDOMClient from 'react-dom/client' | ||
import './app.css' | ||
import App from './app.tsx' | ||
import { ConfigProvider } from './context/config-context.tsx' | ||
import { ServiceWorkerProvider } from './context/service-worker-context.tsx' | ||
import { loadConfigFromLocalStorage } from './lib/config-db.ts' | ||
import { isPathOrSubdomainRequest } from './lib/path-or-subdomain.ts' | ||
import RedirectPage from './redirectPage.tsx' | ||
|
||
await loadConfigFromLocalStorage() | ||
|
||
// SW did not trigger for this request | ||
const container = document.getElementById('root') | ||
|
||
const sw = await navigator.serviceWorker.register(new URL('sw.ts', import.meta.url)) | ||
const root = ReactDOMClient.createRoot(container) | ||
|
||
// SW did not trigger for this request | ||
if (isPathOrSubdomainRequest(window.location)) { | ||
// but the requested path is something it should, so show redirect and redirect to the same URL | ||
root.render( | ||
<RedirectPage /> | ||
) | ||
window.location.replace(window.location.href) | ||
} else { | ||
// TODO: add detection of DNSLink gateways (alowing use with Host: en.wikipedia-on-ipfs.org) | ||
// the requested path is not recognized as a path or subdomain request, so render the app UI | ||
if (window.location.pathname !== '/') { | ||
// pathname is not blank, but is invalid. redirect to the root | ||
window.location.replace('/') | ||
} else { | ||
root.render( | ||
<React.StrictMode> | ||
<App /> | ||
</React.StrictMode> | ||
) | ||
} | ||
} | ||
|
||
// always update the service worker | ||
void sw.update() | ||
root.render( | ||
<React.StrictMode> | ||
<ServiceWorkerProvider> | ||
<ConfigProvider expanded={window.location.pathname === '/config'}> | ||
<App /> | ||
</ConfigProvider> | ||
</ServiceWorkerProvider> | ||
</React.StrictMode> | ||
) | ||
Comment on lines
+16
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. simplified root logic. page rendering determined in app.tsx |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,13 +6,5 @@ export enum COLORS { | |
} | ||
|
||
export enum ChannelActions { | ||
GET_FILE = 'GET_FILE', | ||
DIAL = 'DIAL', | ||
UPDATE_OUTPUT = 'UPDATE_OUTPUT', | ||
PING = 'PING', | ||
PONG = 'PONG', | ||
/** | ||
* Intended only for pushing from SW to the UI output terminal | ||
*/ | ||
SHOW_STATUS = 'SHOW_STATUS', | ||
RELOAD_CONFIG = 'RELOAD_CONFIG', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we probably need a |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,12 @@ | ||
import { getLocalStorageKey } from './local-storage.ts' | ||
import debugLib from 'debug' | ||
import { LOCAL_STORAGE_KEYS } from './local-storage.ts' | ||
import { log } from './logger' | ||
|
||
export interface ConfigDb { | ||
gateways: string[] | ||
routers: string[] | ||
autoReload: boolean | ||
debug: string | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you can use https://npmjs.com/package/debug strings to set debug levels on the service worker. similar to setting |
||
} | ||
|
||
export type configDbKeys = keyof ConfigDb | ||
|
@@ -49,26 +53,47 @@ export async function closeDatabase (db: IDBDatabase): Promise<void> { | |
export async function loadConfigFromLocalStorage (): Promise<void> { | ||
if (typeof globalThis.localStorage !== 'undefined') { | ||
const db = await openDatabase() | ||
const localStorage = global.localStorage | ||
const localStorageGatewaysString = localStorage.getItem(getLocalStorageKey('config', 'gateways')) ?? '[]' | ||
const localStorageRoutersString = localStorage.getItem(getLocalStorageKey('config', 'routers')) ?? '[]' | ||
const localStorage = globalThis.localStorage | ||
const localStorageGatewaysString = localStorage.getItem(LOCAL_STORAGE_KEYS.config.gateways) ?? '[]' | ||
const localStorageRoutersString = localStorage.getItem(LOCAL_STORAGE_KEYS.config.routers) ?? '[]' | ||
const autoReload = localStorage.getItem(LOCAL_STORAGE_KEYS.config.autoReload) === 'true' | ||
const debug = localStorage.getItem(LOCAL_STORAGE_KEYS.config.debug) ?? '' | ||
const gateways = JSON.parse(localStorageGatewaysString) | ||
const routers = JSON.parse(localStorageRoutersString) | ||
debugLib.enable(debug) | ||
|
||
await setInDatabase(db, 'gateways', gateways) | ||
await setInDatabase(db, 'routers', routers) | ||
await setInDatabase(db, 'autoReload', autoReload) | ||
await setInDatabase(db, 'debug', debug) | ||
await closeDatabase(db) | ||
} | ||
} | ||
|
||
export async function setConfig (config: ConfigDb): Promise<void> { | ||
log('config-debug: setting config', config) | ||
debugLib.enable(config.debug ?? '') | ||
const db = await openDatabase() | ||
await setInDatabase(db, 'gateways', config.gateways) | ||
await setInDatabase(db, 'routers', config.routers) | ||
await setInDatabase(db, 'autoReload', config.autoReload) | ||
await setInDatabase(db, 'debug', config.debug ?? '') | ||
await closeDatabase(db) | ||
} | ||
|
||
export async function getConfig (): Promise<ConfigDb> { | ||
const db = await openDatabase() | ||
|
||
const gateways = await getFromDatabase(db, 'gateways') ?? [] | ||
const routers = await getFromDatabase(db, 'routers') ?? [] | ||
const autoReload = await getFromDatabase(db, 'autoReload') ?? false | ||
const debug = await getFromDatabase(db, 'debug') ?? '' | ||
debugLib.enable(debug) | ||
|
||
return { | ||
gateways: gateways instanceof Array ? gateways : [], | ||
routers: routers instanceof Array ? routers : [] | ||
gateways, | ||
routers, | ||
autoReload, | ||
debug | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import { dnsLinkLabelDecoder, isInlinedDnsLink } from './dns-link-labels.ts' | ||
|
||
export interface UrlParts { | ||
id: string | null | ||
protocol: string | null | ||
parentDomain: string | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. moved this over from sw mostly so i could use this for iframe rendering of the config page now that we dont have BASE_URL. Only change is the addition of this |
||
} | ||
|
||
export function getSubdomainParts (urlString: string): UrlParts { | ||
const labels = new URL(urlString).hostname.split('.') | ||
let id: string | null = null | ||
let protocol: string | null = null | ||
let parentDomain: string = urlString | ||
|
||
// DNS label inspection happens from from right to left | ||
// to work fine with edge cases like docs.ipfs.tech.ipns.foo.localhost | ||
for (let i = labels.length - 1; i >= 0; i--) { | ||
if (labels[i].startsWith('ipfs') || labels[i].startsWith('ipns')) { | ||
protocol = labels[i] | ||
id = labels.slice(0, i).join('.') | ||
parentDomain = labels.slice(i + 1).join('.') | ||
if (protocol === 'ipns' && isInlinedDnsLink(id)) { | ||
// un-inline DNSLink names according to https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header | ||
id = dnsLinkLabelDecoder(id) | ||
} | ||
break | ||
} | ||
} | ||
|
||
return { | ||
id, | ||
parentDomain, | ||
protocol | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,17 @@ | ||
export type LocalStorageRoots = 'config' | 'forms' | ||
|
||
export function getLocalStorageKey (root: LocalStorageRoots, key: string): string { | ||
return `helia-service-worker-gateway.${root}.${key}` | ||
} | ||
|
||
export const LOCAL_STORAGE_KEYS = { | ||
config: { | ||
gateways: getLocalStorageKey('config', 'gateways'), | ||
routers: getLocalStorageKey('config', 'routers'), | ||
autoReload: getLocalStorageKey('config', 'autoReload'), | ||
debug: getLocalStorageKey('config', 'debug') | ||
}, | ||
forms: { | ||
requestPath: getLocalStorageKey('forms', 'requestPath') | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import { logger } from '@libp2p/logger' | ||
|
||
const logObj = logger('helia:service-worker-gateway') | ||
|
||
export const log = logObj | ||
export const error = logObj.error | ||
export const trace = logObj.trace |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,89 @@ | ||
import React from 'react' | ||
import React, { useContext, useEffect, useMemo, useState } from 'react' | ||
import { ServiceWorkerReadyButton } from './components/sw-ready-button.tsx' | ||
import { ServiceWorkerContext } from './context/service-worker-context.tsx' | ||
import { HeliaServiceWorkerCommsChannel } from './lib/channel.ts' | ||
import { setConfig, type ConfigDb } from './lib/config-db.ts' | ||
import { getSubdomainParts } from './lib/get-subdomain-parts' | ||
import { error } from './lib/logger.ts' | ||
|
||
const ConfigIframe = (): JSX.Element => { | ||
const { parentDomain } = getSubdomainParts(window.location.href) | ||
|
||
const iframeSrc = `${window.location.protocol}//${parentDomain}/config?origin=${encodeURIComponent(window.location.origin)}` | ||
|
||
return ( | ||
<iframe id="redirect-config-iframe" src={iframeSrc} style={{ width: '100vw', height: '100vh', border: 'none' }} /> | ||
) | ||
} | ||
|
||
const channel = new HeliaServiceWorkerCommsChannel('WINDOW') | ||
|
||
export default function RedirectPage (): JSX.Element { | ||
const [isAutoReloadEnabled, setIsAutoReloadEnabled] = useState(false) | ||
const { isServiceWorkerRegistered } = useContext(ServiceWorkerContext) | ||
|
||
useEffect(() => { | ||
async function doWork (config: ConfigDb): Promise<void> { | ||
try { | ||
await setConfig(config) | ||
// TODO: use channel.messageAndWaitForResponse to ensure that the config is loaded before proceeding. | ||
channel.postMessage({ target: 'SW', action: 'RELOAD_CONFIG' }) | ||
// try to preload the content | ||
setTimeout(() => { | ||
fetch(window.location.href, { method: 'GET' }).then((response) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when user is done on config page, "load content" or refresh should result in immediate rendering (or at least less wait time) |
||
// eslint-disable-next-line no-console | ||
console.log('response', response) | ||
}).catch((err) => { | ||
// eslint-disable-next-line no-console | ||
console.error('error fetching', err) | ||
}) | ||
}, 500) | ||
} catch (err) { | ||
error('config-debug: error setting config on subdomain', err) | ||
} | ||
|
||
if (config.autoReload) { | ||
setIsAutoReloadEnabled(config.autoReload) | ||
} | ||
} | ||
const listener = (event: MessageEvent): void => { | ||
if (event.data?.source === 'helia-sw-config-iframe') { | ||
const config = event.data?.config | ||
if (config != null) { | ||
void doWork(config as ConfigDb) | ||
} | ||
} | ||
} | ||
window.addEventListener('message', listener) | ||
return () => { | ||
window.removeEventListener('message', listener) | ||
} | ||
}, []) | ||
|
||
const displayString = useMemo(() => { | ||
if (!isServiceWorkerRegistered) { | ||
return 'Registering Helia service worker...' | ||
} | ||
if (isAutoReloadEnabled) { | ||
return 'Redirecting you because Auto Reload is enabled.' | ||
} | ||
|
||
return 'Please save your changes to the config to apply them. You can then refresh the page to load your content.' | ||
}, [isAutoReloadEnabled, isServiceWorkerRegistered]) | ||
|
||
useEffect(() => { | ||
if (isAutoReloadEnabled && isServiceWorkerRegistered) { | ||
window.location.reload() | ||
} | ||
}, [isAutoReloadEnabled, isServiceWorkerRegistered]) | ||
|
||
return ( | ||
<div className="redirect-page"> | ||
<h1>Registering Helia service worker and Redirecting...</h1> | ||
<div className="pa4-l mw7 mv5 center pa4"> | ||
<h3 className="">{displayString}</h3> | ||
<ServiceWorkerReadyButton id="load-content" label='Load content' waitingLabel='Waiting for service worker registration...' onClick={() => { window.location.reload() }} /> | ||
</div> | ||
<ConfigIframe /> | ||
</div> | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import { log } from './lib/logger.ts' | ||
|
||
export async function registerServiceWorker (): Promise<ServiceWorkerRegistration> { | ||
const swRegistration = await navigator.serviceWorker.register(new URL('sw.ts', import.meta.url)) | ||
return new Promise((resolve, reject) => { | ||
swRegistration.addEventListener('updatefound', () => { | ||
const newWorker = swRegistration.installing | ||
newWorker?.addEventListener('statechange', () => { | ||
if (newWorker.state === 'activated') { | ||
log('service worker activated') | ||
resolve(swRegistration) | ||
} | ||
}) | ||
}) | ||
}) | ||
} |
There was a problem hiding this comment.
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....?