Skip to content

Commit 19cdb7c

Browse files
authored
fix: prevent endless loops (#911)
If the service worker code doesn't intercept a request properly we can end up redirecting back to the current page endlessly. Instead use local storage to count the number of times we have redirected to the current page and show an error message if it's too many.
1 parent d78d680 commit 19cdb7c

18 files changed

+781
-672
lines changed

build.js

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -306,20 +306,27 @@ const modifyBuiltFiles = {
306306
// Modify the redirects file last
307307
await modifyRedirects()
308308

309-
// create a CSS config file we will use to get the proper CSS filename
310-
const indexCssFile = Object.keys(metafile.outputs).find(file => file.endsWith('.css') && file.includes('app'))
311-
if (indexCssFile) {
312-
const cssConfigContent = `export const CSS_FILENAME = '${path.basename(indexCssFile)}'`
313-
await fs.writeFile(path.resolve('dist/ipfs-sw-css-config.js'), cssConfigContent)
314-
console.log(`Created dist/ipfs-sw-css-config.js with CSS filename: ${path.basename(indexCssFile)}`)
315-
}
309+
for (const [file, meta] of Object.entries(metafile.outputs)) {
310+
if (meta.entryPoint != null) {
311+
console.info(meta.entryPoint, '->', file)
312+
}
316313

317-
// create an app chunk config file we will use to get the proper app chunk filename for importing all the UI dynamically
318-
const appChunkFile = Object.keys(metafile.outputs).find(file => file.endsWith('.js') && file.includes('app'))
319-
if (appChunkFile) {
320-
const appConfigContent = `export const APP_FILENAME = '${path.basename(appChunkFile)}'`
321-
await fs.writeFile(path.resolve('dist/ipfs-sw-app-config.js'), appConfigContent)
322-
console.log(`Created dist/ipfs-sw-app-config.js with app chunk filename: ${path.basename(appChunkFile)}`)
314+
// create an app chunk config file we will use to get the proper app
315+
// chunk filename for importing all the UI dynamically
316+
if (meta.entryPoint === 'src/ui/index.tsx') {
317+
const appConfigContent = `export const APP_FILENAME = '${path.basename(file)}'`
318+
await fs.writeFile(path.resolve('dist/ipfs-sw-app-config.js'), appConfigContent)
319+
console.log(`Created dist/ipfs-sw-app-config.js with app chunk filename: ${path.basename(file)}`)
320+
}
321+
322+
// create a CSS config file we will use to get the proper CSS filename
323+
// TODO: this is too fragile, it only works because there is a only one
324+
// css file in the output
325+
if (file.endsWith('.css')) {
326+
const cssConfigContent = `export const CSS_FILENAME = '${path.basename(file)}'`
327+
await fs.writeFile(path.resolve('dist/ipfs-sw-css-config.js'), cssConfigContent)
328+
console.log(`Created dist/ipfs-sw-css-config.js with CSS filename: ${path.basename(file)}`)
329+
}
323330
}
324331
})
325332
}
@@ -369,7 +376,13 @@ export const buildOptions = {
369376
format: 'esm',
370377
entryNames: 'ipfs-sw-[name]-[hash]',
371378
assetNames: 'ipfs-sw-[name]-[hash]',
372-
plugins: [replaceImports, renameSwPlugin, updateVersions, modifyBuiltFiles, excludeFilesPlugin(['.eot?#iefix', '.otf', '.woff', '.woff2'])]
379+
plugins: [
380+
replaceImports,
381+
renameSwPlugin,
382+
updateVersions,
383+
modifyBuiltFiles,
384+
excludeFilesPlugin(['.eot?#iefix', '.otf', '.woff', '.woff2'])
385+
]
373386
}
374387

375388
const ctx = await esbuild.context(buildOptions)

src/index.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,31 @@ async function main (): Promise<void> {
165165
return
166166
}
167167

168+
const href = url.toString()
169+
170+
// if we accidentally install an invalid service worker we will get stuck in
171+
// an endless loop of redirects, so count the number of times we have
172+
// redirected to this page and halt the redirects if we do it too many times
173+
const storageKey = `ipfs-sw-${href}-redirects`
174+
const redirects = Number(localStorage.getItem(storageKey) ?? 0)
175+
176+
if (redirects > 5) {
177+
globalThis.serverError = {
178+
url: href,
179+
title: '502 Too many redirects',
180+
error: {
181+
name: 'TooManyRedirects',
182+
message: 'The current page redirected too many times'
183+
},
184+
logs: []
185+
}
186+
187+
await renderUi()
188+
return
189+
}
190+
191+
localStorage.setItem(storageKey, `${redirects + 1}`)
192+
168193
// service worker is now installed so redirect to path or subdomain for data
169194
// so it can intercept the request
170195
window.location.href = url.toString()

src/lib/collecting-logger.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { TypedEventEmitter } from '@libp2p/interface'
2+
import { logger } from '@libp2p/logger'
3+
import { format } from 'weald/format'
4+
import type { ComponentLogger, TypedEventTarget } from '@libp2p/interface'
5+
6+
export interface LogEvents {
7+
log: CustomEvent<string>
8+
}
9+
10+
/**
11+
* Listen for 'log' events to collect logs for operations
12+
*/
13+
export const logEmitter: TypedEventTarget<LogEvents> = new TypedEventEmitter<LogEvents>()
14+
15+
/**
16+
* A log implementation that also emits all log lines as 'log' events on the
17+
* exported `logEmitter`.
18+
*/
19+
export function collectingLogger (prefix?: string): ComponentLogger {
20+
return {
21+
forComponent (name: string) {
22+
return logger(`${prefix == null ? '' : `${prefix}:`}${name}`, {
23+
onLog (fmt: string, ...args: any[]): void {
24+
logEmitter.safeDispatchEvent('log', {
25+
detail: format(fmt.replaceAll('%c', ''), ...args.filter(arg => !`${arg}`.startsWith('color:')))
26+
})
27+
}
28+
})
29+
}
30+
}
31+
}

src/lib/logger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { prefixLogger } from '@libp2p/logger'
2-
import { collectingLogger } from './get-verified-fetch.js'
2+
import { collectingLogger } from './collecting-logger.js'
33
import type { ComponentLogger, Logger } from '@libp2p/interface'
44

55
export const uiLogger = prefixLogger('ui')
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { CURRENT_CACHES } from '../../constants.js'
2+
import { getSwLogger } from '../../lib/logger.js'
3+
import { isSubdomainGatewayRequest } from '../../lib/path-or-subdomain.js'
4+
import type { Handler } from './index.js'
5+
6+
export const assetRequestHandler: Handler = {
7+
name: 'asset-request-handler',
8+
9+
canHandle (url, event) {
10+
const isActualSwAsset = /^.+\/(?:ipfs-sw-).+$/.test(event.request.url)
11+
12+
// if path is not set, then it's a request for index.html which we should
13+
// consider a sw asset
14+
15+
// but only if it's not a subdomain request (root index.html should not be
16+
// returned for subdomains)
17+
const isIndexHtmlRequest = url.pathname === '/' && !isSubdomainGatewayRequest(url)
18+
19+
return isActualSwAsset || isIndexHtmlRequest
20+
},
21+
22+
async handle (url: URL, event: FetchEvent) {
23+
const log = getSwLogger('asset-handler')
24+
25+
// return the asset from the cache if it exists, otherwise fetch it.
26+
const cache = await caches.open(CURRENT_CACHES.swAssets)
27+
28+
try {
29+
const cachedResponse = await cache.match(event.request)
30+
31+
if (cachedResponse != null) {
32+
return cachedResponse
33+
}
34+
} catch (err) {
35+
log.error('error matching cached response - %e', err)
36+
}
37+
38+
const response = await fetch(event.request)
39+
40+
try {
41+
await cache.put(event.request, response.clone())
42+
} catch (err) {
43+
log.error('error caching response - %e', err)
44+
}
45+
46+
return response
47+
}
48+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { QUERY_PARAMS } from '../../lib/constants.js'
2+
import { updateVerifiedFetch } from '../lib/verified-fetch.js'
3+
import type { Handler } from './index.js'
4+
5+
export const configReloadHandler: Handler = {
6+
name: 'config-reload-handler',
7+
8+
canHandle (url, event) {
9+
return url.searchParams.has(QUERY_PARAMS.RELOAD_CONFIG)
10+
},
11+
12+
async handle () {
13+
await updateVerifiedFetch()
14+
15+
return new Response('Reloaded configuration', {
16+
status: 200
17+
})
18+
}
19+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { QUERY_PARAMS } from '../../lib/constants.js'
2+
import { createSearch } from '../../lib/first-hit-helpers.js'
3+
import { updateConfig } from '../lib/config.js'
4+
import type { Handler } from './index.js'
5+
6+
export const configUpdateHandler: Handler = {
7+
name: 'config-update-handler',
8+
9+
canHandle (url, event) {
10+
return url.searchParams.has(QUERY_PARAMS.CONFIG)
11+
},
12+
13+
async handle (url: URL, event: FetchEvent) {
14+
// if there is compressed config in the request, apply it
15+
await updateConfig(url, event.request.referrer)
16+
17+
// remove config param from url and redirect
18+
const search = createSearch(url.searchParams, {
19+
filter: (key) => key !== QUERY_PARAMS.CONFIG
20+
})
21+
22+
return new Response('Redirecting after config update', {
23+
status: 307,
24+
headers: {
25+
'Content-Type': 'text/plain',
26+
Location: new URL(`${url.protocol}//${url.host}${url.pathname}${search}${url.hash}`).toString()
27+
}
28+
})
29+
}
30+
}

0 commit comments

Comments
 (0)