diff --git a/build.js b/build.js index 1005691e..f2901723 100644 --- a/build.js +++ b/build.js @@ -5,6 +5,29 @@ import fs from 'node:fs/promises' import path from 'node:path' import esbuild from 'esbuild' +const getConfigFilePath = () => { + if (process.env.NODE_ENV === 'test') { + return path.resolve('src/config/index-test.ts') + } + + if (process.env.NODE_ENV === 'development') { + return path.resolve('src/config/index-development.ts') + } + + return path.resolve('src/config/index.ts') +} + +const connectionHintTags = async () => { + const configPath = getConfigFilePath() + const content = await fs.readFile(configPath, 'utf8') + const matches = content.match(/https?:\/\/[^'"\s]+/g) ?? [] + const origins = [...new Set(matches.map(url => new URL(url).origin))] + + return origins + .map(origin => ``) + .join('\n ') +} + const copyPublicFiles = async () => { const srcDir = path.resolve('public') const destDir = path.resolve('dist') @@ -66,6 +89,7 @@ function gitRevision () { * @param {string} revision - Pre-computed Git revision string */ const injectHtmlPages = async (metafile, revision) => { + const hints = await connectionHintTags() const htmlFilePaths = await fs.readdir(path.resolve('dist'), { withFileTypes: true }) .then(files => files.filter(file => file.isFile() && file.name.endsWith('.html'))) .then(files => files.map(file => path.resolve('dist', file.name))) @@ -124,6 +148,11 @@ const injectHtmlPages = async (metafile, revision) => { } } + if (htmlContent.includes('<%= CONNECTION_HINTS %>')) { + htmlContent = htmlContent.replace(/<%= CONNECTION_HINTS %>/g, hints) + console.log(`Injected connection hints into ${path.relative(process.cwd(), htmlFilePath)}.`) + } + if (!htmlContent.includes('<%= GIT_VERSION %>')) { // if you see this error, make sure you update the HTML file to include an html comment similar to the one in public/index.html throw new Error(`${path.relative(process.cwd(), htmlFilePath)} does not contain <%= GIT_VERSION %>.`) diff --git a/public/index.html b/public/index.html index c2893fe7..b8ce4f36 100644 --- a/public/index.html +++ b/public/index.html @@ -23,6 +23,7 @@ IPFS Service Worker Gateway | <%= GIT_VERSION %> + <%= CONNECTION_HINTS %> <%= CSS_STYLES %> diff --git a/src/index.tsx b/src/index.tsx index 369c8e3f..c386cde7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,7 @@ import { config } from './config/index.ts' import { QUERY_PARAMS } from './lib/constants.ts' import { parseRequest } from './lib/parse-request.ts' import { isSafeOrigin } from './lib/path-or-subdomain.ts' +import { prewarmContentRequest } from './lib/prewarm-content.ts' import { registerServiceWorker } from './lib/register-service-worker.ts' import type { ResolvableURI } from './lib/parse-request.ts' @@ -159,6 +160,8 @@ async function main (): Promise { const hasActiveWorker = registration?.active != null if (!hasActiveWorker) { + prewarmContentRequest(request, config) + // install the service worker on the root path of this domain, either // path or subdomain gateway await registerServiceWorker() diff --git a/src/lib/prewarm-content.ts b/src/lib/prewarm-content.ts new file mode 100644 index 00000000..70faacc2 --- /dev/null +++ b/src/lib/prewarm-content.ts @@ -0,0 +1,79 @@ +import { base36 } from 'multiformats/bases/base36' +import type { ResolvableURI } from './parse-request.ts' +import type { Config } from '../config/index.ts' + +const FILTER_ADDRS = 'wss,tls,https' +const FILTER_PROTOCOLS = 'unknown,transport-bitswap,transport-ipfs-gateway-http' + +function toArray (value: string | string[]): string[] { + return Array.isArray(value) ? value : [value] +} + +function joinPath (basePathname: string, suffix: string): string { + const base = basePathname.endsWith('/') ? basePathname.slice(0, -1) : basePathname + + if (base.length === 0) { + return suffix + } + + return `${base}${suffix}` +} + +function warmRequest (url: URL): void { + void fetch(url.toString(), { + method: 'GET', + mode: 'cors', + credentials: 'omit', + cache: 'force-cache', + keepalive: true + }).catch(() => { + // ignore prewarm errors, this is a best-effort optimization + }) +} + +export function buildPrewarmUrls (request: ResolvableURI, options: Pick): URL[] { + const urls: URL[] = [] + const routers = options.routers.map(router => new URL(router)) + const resolvers = Object.values(options.dnsResolvers) + .flatMap(value => toArray(value)) + .map(resolver => new URL(resolver)) + + if (request.type === 'internal' || request.type === 'external') { + return urls + } + + if (request.protocol === 'ipfs') { + routers.forEach(router => { + const url = new URL(router.toString()) + url.pathname = joinPath(url.pathname, `/routing/v1/providers/${request.cid.toString()}`) + url.searchParams.set('filter-addrs', FILTER_ADDRS) + url.searchParams.set('filter-protocols', FILTER_PROTOCOLS) + urls.push(url) + }) + } + + if (request.protocol === 'ipns') { + routers.forEach(router => { + const url = new URL(router.toString()) + url.pathname = joinPath(url.pathname, `/routing/v1/ipns/${request.peerId.toCID().toString(base36)}`) + urls.push(url) + }) + } + + if (request.protocol === 'dnslink') { + resolvers.forEach(resolver => { + const url = new URL(resolver.toString()) + url.searchParams.set('name', `_dnslink.${request.domain}`) + url.searchParams.set('type', 'TXT') + urls.push(url) + }) + } + + return urls +} + +export function prewarmContentRequest (request: ResolvableURI, options: Pick): void { + buildPrewarmUrls(request, options).forEach(url => { + warmRequest(url) + }) +} diff --git a/test/lib/prewarm-content.spec.ts b/test/lib/prewarm-content.spec.ts new file mode 100644 index 00000000..7e76f6d3 --- /dev/null +++ b/test/lib/prewarm-content.spec.ts @@ -0,0 +1,39 @@ +import { expect } from 'aegir/chai' +import { dnsLinkLabelEncoder } from '../../src/lib/dns-link-labels.ts' +import { parseRequest } from '../../src/lib/parse-request.ts' +import { buildPrewarmUrls } from '../../src/lib/prewarm-content.ts' + +const options = { + routers: ['https://delegated-ipfs.dev'], + dnsResolvers: { + '.': 'https://delegated-ipfs.dev/dns-query' + } +} + +describe('prewarm-content', () => { + it('builds provider query for ipfs requests', () => { + const request = parseRequest(new URL('https://bafyaaaa.ipfs.localhost:3000/'), new URL('https://localhost:3000')) + const urls = buildPrewarmUrls(request, options) + + expect(urls).to.have.lengthOf(1) + expect(urls[0].toString()).to.equal('https://delegated-ipfs.dev/routing/v1/providers/bafyaaaa?filter-addrs=wss%2Ctls%2Chttps&filter-protocols=unknown%2Ctransport-bitswap%2Ctransport-ipfs-gateway-http') + }) + + it('builds routing query for ipns requests', () => { + const ipnsName = 'k51qzi5uqu5djpzsgwway43y9oc6p6zf2mco05x7yo6njcs9n1u4s19416t69w' + const request = parseRequest(new URL(`https://${ipnsName}.ipns.localhost:3000/`), new URL('https://localhost:3000')) + const urls = buildPrewarmUrls(request, options) + + expect(urls).to.have.lengthOf(1) + expect(urls[0].toString()).to.equal(`https://delegated-ipfs.dev/routing/v1/ipns/${ipnsName}`) + }) + + it('builds dns query for dnslink requests', () => { + const domain = 'docs.ipfs.tech' + const request = parseRequest(new URL(`https://${dnsLinkLabelEncoder(domain)}.ipns.localhost:3000/`), new URL('https://localhost:3000')) + const urls = buildPrewarmUrls(request, options) + + expect(urls).to.have.lengthOf(1) + expect(urls[0].toString()).to.equal('https://delegated-ipfs.dev/dns-query?name=_dnslink.docs.ipfs.tech&type=TXT') + }) +})