Skip to content

Commit

Permalink
fix: ipfs-hosted redirects are not infinite (#215)
Browse files Browse the repository at this point in the history
* tmp: add ipfs-gateway for hosting local dist like inbrowser.tld

* chore: loading sw-gateway at localhost:3334 via ipfs host locally

* chore: code cleanup

* test: working on ipfs-hosted e2e test

* fix: infinite redirects are handled appropriately

* chore: fix e2e test servers logging

* docs: updating comments for ?helia-sw redirects
  • Loading branch information
SgtPooki authored Apr 23, 2024
1 parent a53ddf7 commit 40cc8c7
Show file tree
Hide file tree
Showing 11 changed files with 292 additions and 54 deletions.
10 changes: 10 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ export default defineConfig({
stdout: process.env.CI ? undefined : 'pipe',
stderr: process.env.CI ? undefined : 'pipe'
},
{
command: 'node test-e2e/ipfs-gateway.js',
timeout: 5 * 1000,
env: {
PROXY_PORT: '3334',
GATEWAY_PORT: '8088'
},
stdout: process.env.CI ? undefined : 'pipe',
stderr: process.env.CI ? undefined : 'pipe'
},
{
// need to use built assets due to service worker loading issue.
// TODO: figure out how to get things working with npm run start
Expand Down
20 changes: 10 additions & 10 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import 'preact/debug'
import React, { render } from 'preact/compat'
import App from './app.jsx'
import { ConfigProvider } from './context/config-context.jsx'
import { RouterProvider, type Route } from './context/router-context.jsx'
import { ServiceWorkerProvider } from './context/service-worker-context.jsx'

// SW did not trigger for this request
const container = document.getElementById('root')
Expand All @@ -23,18 +21,20 @@ const routes: Route[] = [
{ default: true, component: LazyHelperUi },
{ shouldRender: async () => (await import('./lib/routing-render-checks.js')).shouldRenderRedirectsInterstitial(), component: LazyInterstitial },
{ path: '#/ipfs-sw-config', shouldRender: async () => (await import('./lib/routing-render-checks.js')).shouldRenderConfigPage(), component: LazyConfig },
{ shouldRender: async () => (await import('./lib/routing-render-checks.js')).shouldRenderRedirectPage(), component: LazyRedirectPage }
{
shouldRender: async () => {
const renderChecks = await import('./lib/routing-render-checks.js')
return renderChecks.shouldRenderRedirectPage()
},
component: LazyRedirectPage
}
]

render(
<React.StrictMode>
<ServiceWorkerProvider>
<ConfigProvider>
<RouterProvider routes={routes}>
<App />
</RouterProvider>
</ConfigProvider>
</ServiceWorkerProvider>
<RouterProvider routes={routes}>
<App />
</RouterProvider>
</React.StrictMode>,
container
)
12 changes: 11 additions & 1 deletion src/lib/routing-render-checks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
/**
* We should load the redirect page if:
*
* 1. The request is the first hit on the subdomain
* - but NOT if subdomains are supported and we're not currently on the subdomain.
* i.e. example.com?helia-sw=/ipfs/blah will hit shouldRenderRedirectsInterstitial, which will redirect to blah.ipfs.example.com, which will THEN return true from shouldRenderRedirectPage
* 2. The request is not an explicit request to view the config page
* 3. The request would otherwise be handled by the service worker but it's not yet registered.
*/
export async function shouldRenderRedirectPage (): Promise<boolean> {
const { isConfigPage } = await import('../lib/is-config-page.js')
const { isPathOrSubdomainRequest } = await import('./path-or-subdomain.js')
Expand All @@ -6,6 +15,7 @@ export async function shouldRenderRedirectPage (): Promise<boolean> {
const isTopLevelWindow = window.self === window.top
const isRequestToViewConfigPageAndTopLevelWindow = isRequestToViewConfigPage && isTopLevelWindow
const result = shouldRequestBeHandledByServiceWorker && !isRequestToViewConfigPageAndTopLevelWindow

return result
}

Expand All @@ -16,7 +26,7 @@ export async function shouldRenderConfigPage (): Promise<boolean> {
return isRequestToViewConfigPage
}

export async function shouldRenderRedirectsInterstitial (): Promise<boolean> {
export function shouldRenderRedirectsInterstitial (): boolean {
const url = new URL(window.location.href)
const heliaSw = url.searchParams.get('helia-sw')
return heliaSw != null
Expand Down
16 changes: 16 additions & 0 deletions src/lib/translate-ipfs-redirect-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* If you host helia-service-worker-gateway with an IPFS gateway, the _redirects file will route some requests from
* `<domain>/<wildcard-splat>` to `https://<domain>/?helia-sw=<wildcard-splat>` when they hit the server instead of
* the service worker. This only occurs when the service worker is not yet registered.
*
* This function will check for "?helia-sw=" in the URL and modify the URL so that it works with the rest of our logic
*/
export function translateIpfsRedirectUrl (urlString: string): URL {
const url = new URL(urlString)
const heliaSw = url.searchParams.get('helia-sw')
if (heliaSw != null) {
url.searchParams.delete('helia-sw')
url.pathname = heliaSw
}
return url
}
14 changes: 13 additions & 1 deletion src/pages/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { Collapsible } from '../components/collapsible.jsx'
import LocalStorageInput from '../components/local-storage-input.jsx'
import { LocalStorageToggle } from '../components/local-storage-toggle.jsx'
import { ServiceWorkerReadyButton } from '../components/sw-ready-button.jsx'
import { ConfigProvider } from '../context/config-context.jsx'
import { RouteContext } from '../context/router-context.jsx'
import { ServiceWorkerProvider } from '../context/service-worker-context.jsx'
import { HeliaServiceWorkerCommsChannel } from '../lib/channel.js'
import { getConfig, loadConfigFromLocalStorage } from '../lib/config-db.js'
import { LOCAL_STORAGE_KEYS } from '../lib/local-storage.js'
Expand Down Expand Up @@ -34,7 +36,7 @@ const stringValidationFn = (value: string): Error | null => {
return null
}

export default (): React.JSX.Element | null => {
function ConfigPage (): React.JSX.Element | null {
const { gotoPage } = React.useContext(RouteContext)
const [error, setError] = useState<Error | null>(null)

Expand Down Expand Up @@ -96,3 +98,13 @@ export default (): React.JSX.Element | null => {
</main>
)
}

export default (): React.JSX.Element => {
return (
<ServiceWorkerProvider>
<ConfigProvider>
<ConfigPage />
</ConfigProvider>
</ServiceWorkerProvider>
)
}
14 changes: 13 additions & 1 deletion src/pages/helper-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import React, { useState, useEffect } from 'preact/compat'
import Form from '../components/Form.jsx'
import Header from '../components/Header.jsx'
import CidRenderer from '../components/input-validator.jsx'
import { ConfigProvider } from '../context/config-context.jsx'
import { ServiceWorkerProvider } from '../context/service-worker-context.jsx'
import { LOCAL_STORAGE_KEYS } from '../lib/local-storage.js'

export default function (): React.JSX.Element {
function HelperUi (): React.JSX.Element {
const [requestPath, setRequestPath] = useState(localStorage.getItem(LOCAL_STORAGE_KEYS.forms.requestPath) ?? '')

useEffect(() => {
Expand Down Expand Up @@ -34,3 +36,13 @@ export default function (): React.JSX.Element {
</>
)
}

export default (): React.JSX.Element => {
return (
<ServiceWorkerProvider>
<ConfigProvider>
<HelperUi />
</ConfigProvider>
</ServiceWorkerProvider>
)
}
26 changes: 19 additions & 7 deletions src/pages/redirect-page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { useContext, useEffect, useMemo, useState } from 'preact/compat'
import { ServiceWorkerReadyButton } from '../components/sw-ready-button.jsx'
import { ServiceWorkerContext } from '../context/service-worker-context.jsx'
import { ConfigProvider } from '../context/config-context.jsx'
import { ServiceWorkerContext, ServiceWorkerProvider } from '../context/service-worker-context.jsx'
import { HeliaServiceWorkerCommsChannel } from '../lib/channel.js'
import { setConfig, type ConfigDb } from '../lib/config-db.js'
import { getSubdomainParts } from '../lib/get-subdomain-parts.js'
import { isConfigPage } from '../lib/is-config-page.js'
import { error, trace } from '../lib/logger.js'
import { translateIpfsRedirectUrl } from '../lib/translate-ipfs-redirect-url.js'

const ConfigIframe = (): React.JSX.Element => {
const { parentDomain } = getSubdomainParts(window.location.href)
Expand All @@ -27,11 +29,16 @@ const ConfigIframe = (): React.JSX.Element => {

const channel = new HeliaServiceWorkerCommsChannel('WINDOW')

export default function RedirectPage ({ showConfigIframe = true }: { showConfigIframe?: boolean }): React.JSX.Element {
function RedirectPage ({ showConfigIframe = true }: { showConfigIframe?: boolean }): React.JSX.Element {
const [isAutoReloadEnabled, setIsAutoReloadEnabled] = useState(false)
const { isServiceWorkerRegistered } = useContext(ServiceWorkerContext)
const [reloadUrl, setReloadUrl] = useState(translateIpfsRedirectUrl(window.location.href).href)

useEffect(() => {
if (isConfigPage(window.location.hash)) {
setReloadUrl(window.location.href.replace('#/ipfs-sw-config', ''))
}

async function doWork (config: ConfigDb): Promise<void> {
try {
await setConfig(config)
Expand Down Expand Up @@ -63,11 +70,6 @@ export default function RedirectPage ({ showConfigIframe = true }: { showConfigI
}
}, [])

let reloadUrl = window.location.href
if (isConfigPage(window.location.hash)) {
reloadUrl = window.location.href.replace('#/ipfs-sw-config', '')
}

const displayString = useMemo(() => {
if (!isServiceWorkerRegistered) {
return 'Registering Helia service worker...'
Expand Down Expand Up @@ -95,3 +97,13 @@ export default function RedirectPage ({ showConfigIframe = true }: { showConfigI
</div>
)
}

export default (): React.JSX.Element => {
return (
<ServiceWorkerProvider>
<ConfigProvider>
<RedirectPage />
</ConfigProvider>
</ServiceWorkerProvider>
)
}
77 changes: 53 additions & 24 deletions src/pages/redirects-interstitial.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,63 @@
import React from 'preact/compat'
import React, { useEffect } from 'preact/compat'
import { findOriginIsolationRedirect } from '../lib/path-or-subdomain.js'
import { translateIpfsRedirectUrl } from '../lib/translate-ipfs-redirect-url.js'
import RedirectPage from './redirect-page'

/**
* This page is only used to capture the ?helia-sw=/ip[fn]s/blah query parameter that
* is used by IPFS hosted versions of the service-worker-gateway when non-existent paths are requested.
* This will only redirect if the URL is for a subdomain
*/
export default function RedirectsInterstitial (): React.JSX.Element {
const windowLocation = translateIpfsRedirectUrl(window.location.href)
if (windowLocation.href !== window.location.href) {
/**
* We're at a domain with ?helia-sw=, we can reload the page so the service worker will
* capture the request
*/
window.location.replace(windowLocation.href)
}
const [subdomainRedirectUrl, setSubdomainRedirectUrl] = React.useState<string | null>(null)
const [isSubdomainCheckDone, setIsSubdomainCheckDone] = React.useState<boolean>(false)
useEffect(() => {
async function doWork (): Promise<void> {
setSubdomainRedirectUrl(await findOriginIsolationRedirect(translateIpfsRedirectUrl(window.location.href)))
setIsSubdomainCheckDone(true)
}
void doWork()
})

return (<>First-hit on IPFS hosted service-worker-gateway. Reloading</>)
}
useEffect(() => {
if (subdomainRedirectUrl != null && window.location.href !== subdomainRedirectUrl) {
/**
* We're at a domain with ?helia-sw=, we can reload the page so the service worker will
* capture the request
*/
window.location.replace(subdomainRedirectUrl)
}
}, [subdomainRedirectUrl])

/**
* If you host helia-service-worker-gateway on an IPFS domain, the redirects file will route some requests from
* `<domain>/<wildcard-splat>` to `https://<domain>/?helia-sw=<wildcard-splat>`.
*
* This function will check for "?helia-sw=" in the URL and modify the URL so that it works with the rest of our logic
*/
function translateIpfsRedirectUrl (urlString: string): URL {
const url = new URL(urlString)
const heliaSw = url.searchParams.get('helia-sw')
if (heliaSw != null) {
url.searchParams.delete('helia-sw')
url.pathname = heliaSw
if (!isSubdomainCheckDone) {
/**
* We're waiting for the subdomain check to complete.. this will look like a FOUC (Flash Of Unstyled Content) when
* the assets are quickly loaded, but are informative to users when loading of assets is slow.
*
* TODO: Better styling.
*/
return (<>First-hit on IPFS hosted service-worker-gateway. Determining state...</>)
}
return url

if (subdomainRedirectUrl == null) {
/**
* We now render the redirect page if ?helia-sw is observed and subdomain redirect is not required., but not by
* conflating logic into the actual RedirectPage component.
*
* However, the url in the browser for this scenario will be "<domain>/?helia-sw=/ipfs/blah", and the RedirectPage
* will update that URL when the user clicks "load Content" to "<domain>/ipfs/blah".
*/
return <RedirectPage />
}

/**
* If we redirect to a subdomain, this page will not be rendered again for the requested URL. The `RedirectPage`
* component will render directly.
*
* This page should also not render again for any subsequent unique urls because the SW is registered and would
* trigger the redirect logic, which would then load RedirectPage if a "first-hit" for that subdomain.
*
* TODO: Better styling.
*/
return <>Waiting for redirect to subdomain...</>
}
16 changes: 9 additions & 7 deletions test-e2e/first-hit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ test.describe('first-hit ipfs-hosted', () => {
}
})
test('redirects to ?helia-sw=<path> are handled', async ({ page }) => {
const response = await page.goto('http://127.0.0.1:3333/?helia-sw=/ipfs/bafkqablimvwgy3y', { waitUntil: 'commit' })
const response = await page.goto('http://127.0.0.1:3334/ipfs/bafkqablimvwgy3y')

expect(response?.url()).toBe('http://127.0.0.1:3334/?helia-sw=/ipfs/bafkqablimvwgy3y')

// first loads the root page
expect(response?.status()).toBe(200)
Expand All @@ -21,12 +23,10 @@ test.describe('first-hit ipfs-hosted', () => {
expect(headers?.['content-type']).toContain('text/html')

// then we should be redirected to the IPFS path
await page.waitForURL('http://127.0.0.1:3333/ipfs/bafkqablimvwgy3y')

// and then the normal redirectPage logic:
await waitForServiceWorker(page)
const bodyTextLocator = page.locator('body')
await expect(bodyTextLocator).toContainText('Please save your changes to the config to apply them')
// and then the normal redirectPage logic:
await waitForServiceWorker(page)

// it should render the config iframe
await expect(page.locator('#redirect-config-iframe')).toBeAttached({ timeout: 1 })
Expand All @@ -43,7 +43,9 @@ test.describe('first-hit ipfs-hosted', () => {

test.describe('subdomain-routing', () => {
test('redirects to ?helia-sw=<path> are handled', async ({ page, rootDomain, protocol }) => {
const response = await page.goto(`${protocol}//${rootDomain}/?helia-sw=/ipfs/bafkqablimvwgy3y`, { waitUntil: 'commit' })
const response = await page.goto('http://localhost:3334/ipfs/bafkqablimvwgy3y')

expect(response?.url()).toBe('http://localhost:3334/?helia-sw=/ipfs/bafkqablimvwgy3y')

// first loads the root page
expect(response?.status()).toBe(200)
Expand All @@ -53,7 +55,7 @@ test.describe('first-hit ipfs-hosted', () => {

// then we should be redirected to the IPFS path
const bodyTextLocator = page.locator('body')
await page.waitForURL(`${protocol}//bafkqablimvwgy3y.ipfs.${rootDomain}`)
await page.waitForURL('http://bafkqablimvwgy3y.ipfs.localhost:3334')
await expect(bodyTextLocator).toContainText('Registering Helia service worker')

await waitForServiceWorker(page)
Expand Down
Loading

0 comments on commit 40cc8c7

Please sign in to comment.