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')
+ })
+})