diff --git a/playwright.config.js b/playwright.config.js index e55b1240..3faf2b35 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -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 diff --git a/src/index.tsx b/src/index.tsx index e35aeb70..68ddd4f8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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') @@ -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( - - - - - - - + + + , container ) diff --git a/src/lib/routing-render-checks.ts b/src/lib/routing-render-checks.ts index e0ee92aa..c1caa38c 100644 --- a/src/lib/routing-render-checks.ts +++ b/src/lib/routing-render-checks.ts @@ -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 { const { isConfigPage } = await import('../lib/is-config-page.js') const { isPathOrSubdomainRequest } = await import('./path-or-subdomain.js') @@ -6,6 +15,7 @@ export async function shouldRenderRedirectPage (): Promise { const isTopLevelWindow = window.self === window.top const isRequestToViewConfigPageAndTopLevelWindow = isRequestToViewConfigPage && isTopLevelWindow const result = shouldRequestBeHandledByServiceWorker && !isRequestToViewConfigPageAndTopLevelWindow + return result } @@ -16,7 +26,7 @@ export async function shouldRenderConfigPage (): Promise { return isRequestToViewConfigPage } -export async function shouldRenderRedirectsInterstitial (): Promise { +export function shouldRenderRedirectsInterstitial (): boolean { const url = new URL(window.location.href) const heliaSw = url.searchParams.get('helia-sw') return heliaSw != null diff --git a/src/lib/translate-ipfs-redirect-url.ts b/src/lib/translate-ipfs-redirect-url.ts new file mode 100644 index 00000000..2681a89c --- /dev/null +++ b/src/lib/translate-ipfs-redirect-url.ts @@ -0,0 +1,16 @@ +/** + * If you host helia-service-worker-gateway with an IPFS gateway, the _redirects file will route some requests from + * `/` to `https:///?helia-sw=` 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 +} diff --git a/src/pages/config.tsx b/src/pages/config.tsx index c08b0d64..321de8a2 100644 --- a/src/pages/config.tsx +++ b/src/pages/config.tsx @@ -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' @@ -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(null) @@ -96,3 +98,13 @@ export default (): React.JSX.Element | null => { ) } + +export default (): React.JSX.Element => { + return ( + + + + + + ) +} diff --git a/src/pages/helper-ui.tsx b/src/pages/helper-ui.tsx index 94abf823..e1367494 100644 --- a/src/pages/helper-ui.tsx +++ b/src/pages/helper-ui.tsx @@ -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(() => { @@ -34,3 +36,13 @@ export default function (): React.JSX.Element { ) } + +export default (): React.JSX.Element => { + return ( + + + + + + ) +} diff --git a/src/pages/redirect-page.tsx b/src/pages/redirect-page.tsx index 33c36dad..4ee0f061 100644 --- a/src/pages/redirect-page.tsx +++ b/src/pages/redirect-page.tsx @@ -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) @@ -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 { try { await setConfig(config) @@ -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...' @@ -95,3 +97,13 @@ export default function RedirectPage ({ showConfigIframe = true }: { showConfigI ) } + +export default (): React.JSX.Element => { + return ( + + + + + + ) +} diff --git a/src/pages/redirects-interstitial.tsx b/src/pages/redirects-interstitial.tsx index bb86ed4d..2d6e8b91 100644 --- a/src/pages/redirects-interstitial.tsx +++ b/src/pages/redirects-interstitial.tsx @@ -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(null) + const [isSubdomainCheckDone, setIsSubdomainCheckDone] = React.useState(false) + useEffect(() => { + async function doWork (): Promise { + 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 - * `/` to `https:///?helia-sw=`. - * - * 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 "/?helia-sw=/ipfs/blah", and the RedirectPage + * will update that URL when the user clicks "load Content" to "/ipfs/blah". + */ + return + } + + /** + * 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... } diff --git a/test-e2e/first-hit.test.ts b/test-e2e/first-hit.test.ts index 0c6b00d8..a03c5eb3 100644 --- a/test-e2e/first-hit.test.ts +++ b/test-e2e/first-hit.test.ts @@ -12,7 +12,9 @@ test.describe('first-hit ipfs-hosted', () => { } }) test('redirects to ?helia-sw= 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) @@ -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 }) @@ -43,7 +43,9 @@ test.describe('first-hit ipfs-hosted', () => { test.describe('subdomain-routing', () => { test('redirects to ?helia-sw= 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) @@ -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) diff --git a/test-e2e/ipfs-gateway.js b/test-e2e/ipfs-gateway.js new file mode 100644 index 00000000..0cfbc619 --- /dev/null +++ b/test-e2e/ipfs-gateway.js @@ -0,0 +1,114 @@ +/* eslint-disable no-console */ +/** + * This file is used to simulate hosting the dist folder on an ipfs gateway, so we can handle _redirects + */ +import { mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' +import { cwd } from 'node:process' +import { fileURLToPath } from 'node:url' +import { logger } from '@libp2p/logger' +import { $, execa } from 'execa' +import { path } from 'kubo' + +const log = logger('ipfs-host.local') +const daemonLog = logger('ipfs-host.local:kubo') +const proxyLog = logger('ipfs-host.local:proxy') +const __dirname = dirname(fileURLToPath(import.meta.url)) +const tempDir = tmpdir() +const IPFS_PATH = `${tempDir}/.ipfs/${Date.now()}` +const kuboBin = path() + +const gatewayPort = Number(process.env.GATEWAY_PORT ?? 8088) + +/** + * @type {import('execa').Options} + */ +const execaOptions = { + cwd: __dirname, + cleanup: true, + env: { + IPFS_PATH, + GOLOG_LOG_LEVEL: process.env.GOLOG_LOG_LEVEL ?? 'debug,*=debug', + DEBUG: 'reverse-proxy,reverse-proxy:*' + } +} + +try { + await mkdir(IPFS_PATH, { recursive: true }) + await $(execaOptions)`${kuboBin} init` + log('done with init') +} catch (_) { + // ignore +} +log('using IPFS_PATH: ', IPFS_PATH) +const { stdout: cid } = await $(execaOptions)`${kuboBin} add -r -Q ${relative(cwd(), '../dist')} --cid-version 1` + +log('sw-gateway dist CID: ', cid.trim()) +const { stdout: pinStdout } = await $(execaOptions)`${kuboBin} pin add -r /ipfs/${cid.trim()}` +log('pinStdout: ', pinStdout) + +const IPFS_NS_MAP = [['ipfs-host.local', `/ipfs/${cid.trim()}`]].map(([host, path]) => `${host}:${path}`).join(',') + +// @ts-expect-error - it's defined. +execaOptions.env.IPFS_NS_MAP = IPFS_NS_MAP + +await $(execaOptions)`${kuboBin} config Addresses.Gateway /ip4/127.0.0.1/tcp/${gatewayPort}` +await $(execaOptions)`${kuboBin} config Addresses.API /ip4/127.0.0.1/tcp/0` +await $(execaOptions)`${kuboBin} config --json Bootstrap ${JSON.stringify([])}` +await $(execaOptions)`${kuboBin} config --json Swarm.DisableNatPortMap true` +await $(execaOptions)`${kuboBin} config --json Discovery.MDNS.Enabled false` +await $(execaOptions)`${kuboBin} config --json Gateway.NoFetch true` +await $(execaOptions)`${kuboBin} config --json Gateway.DeserializedResponses true` +await $(execaOptions)`${kuboBin} config --json Gateway.ExposeRoutingAPI false` +await $(execaOptions)`${kuboBin} config --json Gateway.HTTPHeaders.Access-Control-Allow-Origin ${JSON.stringify(['*'])}` +await $(execaOptions)`${kuboBin} config --json Gateway.HTTPHeaders.Access-Control-Allow-Methods ${JSON.stringify(['GET', 'POST', 'PUT', 'OPTIONS'])}` + +log('starting kubo') +// need to stand up kubo daemon to serve the dist folder +const daemon = execa(kuboBin, ['daemon', '--offline'], execaOptions) + +if (daemon == null || (daemon.stdout == null || daemon.stderr == null)) { + throw new Error('failed to start kubo daemon') +} +daemon.stdout.on('data', (data) => { + daemonLog(data.toString()) +}) +daemon.stderr.on('data', (data) => { + daemonLog.trace(data.toString()) +}) + +// check for "daemon is ready" message +await new Promise((resolve, reject) => { + daemon.stdout?.on('data', (data) => { + if (data.includes('Daemon is ready')) { + // @ts-expect-error - nothing needed here. + resolve() + log('Kubo daemon is ready') + } + }) + const timeout = setTimeout(() => { + reject(new Error('kubo daemon failed to start')) + clearTimeout(timeout) + }, 5000) +}) + +// @ts-expect-error - overwriting type of NodeJS.ProcessEnv +execaOptions.env = { + ...execaOptions.env, + TARGET_HOST: 'localhost', + BACKEND_PORT: gatewayPort.toString(), + PROXY_PORT: process.env.PROXY_PORT ?? '3334', + SUBDOMAIN: `${cid.trim()}.ipfs`, + DISABLE_TRY_FILES: 'true', + X_FORWARDED_HOST: 'ipfs-host.local', + DEBUG: process.env.DEBUG ?? 'reverse-proxy*,reverse-proxy*:trace' +} +const reverseProxy = execa('node', [`${join(__dirname, 'reverse-proxy.js')}`], execaOptions) + +reverseProxy.stdout?.on('data', (data) => { + proxyLog(data.toString()) +}) +reverseProxy.stderr?.on('data', (data) => { + proxyLog.trace(data.toString()) +}) diff --git a/test-e2e/reverse-proxy.js b/test-e2e/reverse-proxy.js index 0796bcc5..6fc64442 100644 --- a/test-e2e/reverse-proxy.js +++ b/test-e2e/reverse-proxy.js @@ -1,9 +1,16 @@ /* eslint-disable no-console */ import { request, createServer } from 'node:http' +import { logger } from '@libp2p/logger' + +const log = logger('reverse-proxy') const TARGET_HOST = process.env.TARGET_HOST ?? 'localhost' const backendPort = Number(process.env.BACKEND_PORT ?? 3000) const proxyPort = Number(process.env.PROXY_PORT ?? 3333) +const subdomain = process.env.SUBDOMAIN +const prefixPath = process.env.PREFIX_PATH +const disableTryFiles = process.env.DISABLE_TRY_FILES === 'true' +const X_FORWARDED_HOST = process.env.X_FORWARDED_HOST const setCommonHeaders = (res) => { res.setHeader('Access-Control-Allow-Origin', '*') @@ -16,8 +23,22 @@ const makeRequest = (options, req, res, attemptRootFallback = false) => { const clientIp = req.connection.remoteAddress options.headers['X-Forwarded-For'] = clientIp + // override path to include prefixPath if set + if (prefixPath != null) { + options.path = `${prefixPath}${options.path}` + } + if (subdomain != null) { + options.headers.Host = `${subdomain}.${TARGET_HOST}` + } + if (X_FORWARDED_HOST != null) { + options.headers['X-Forwarded-Host'] = X_FORWARDED_HOST + } + + // log where we're making the request to + log('Proxying request to %s:%s%s', options.headers.Host, options.port, options.path) + const proxyReq = request(options, proxyRes => { - if (proxyRes.statusCode === 404) { // poor mans attempt to implement nginx style try_files + if (!disableTryFiles && proxyRes.statusCode === 404) { // poor mans attempt to implement nginx style try_files if (!attemptRootFallback) { // Split the path and pop the last segment const pathSegments = options.path.split('/') @@ -39,7 +60,7 @@ const makeRequest = (options, req, res, attemptRootFallback = false) => { req.pipe(proxyReq, { end: true }) proxyReq.on('error', (e) => { - console.error(`Problem with request: ${e.message}`) + log.error(`Problem with request: ${e.message}`) setCommonHeaders(res) res.writeHead(500) res.end(`Internal Server Error: ${e.message}`) @@ -66,5 +87,5 @@ const proxyServer = createServer((req, res) => { }) proxyServer.listen(proxyPort, () => { - console.log(`Proxy server listening on port ${proxyPort}`) + log(`Proxy server listening on port ${proxyPort}`) })