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}`)
})