Skip to content

Commit 24f00e7

Browse files
committed
feat: support routing prefetching and trustless gateways preconnects
1 parent 4b14cd2 commit 24f00e7

5 files changed

Lines changed: 151 additions & 0 deletions

File tree

build.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,29 @@ import fs from 'node:fs/promises'
55
import path from 'node:path'
66
import esbuild from 'esbuild'
77

8+
const getConfigFilePath = () => {
9+
if (process.env.NODE_ENV === 'test') {
10+
return path.resolve('src/config/index-test.ts')
11+
}
12+
13+
if (process.env.NODE_ENV === 'development') {
14+
return path.resolve('src/config/index-development.ts')
15+
}
16+
17+
return path.resolve('src/config/index.ts')
18+
}
19+
20+
const connectionHintTags = async () => {
21+
const configPath = getConfigFilePath()
22+
const content = await fs.readFile(configPath, 'utf8')
23+
const matches = content.match(/https?:\/\/[^'"\s]+/g) ?? []
24+
const origins = [...new Set(matches.map(url => new URL(url).origin))]
25+
26+
return origins
27+
.map(origin => `<link rel="preconnect" href="${origin}"${origin.startsWith('https://') ? ' crossorigin' : ''} />`)
28+
.join('\n ')
29+
}
30+
831
const copyPublicFiles = async () => {
932
const srcDir = path.resolve('public')
1033
const destDir = path.resolve('dist')
@@ -66,6 +89,7 @@ function gitRevision () {
6689
* @param {string} revision - Pre-computed Git revision string
6790
*/
6891
const injectHtmlPages = async (metafile, revision) => {
92+
const hints = await connectionHintTags()
6993
const htmlFilePaths = await fs.readdir(path.resolve('dist'), { withFileTypes: true })
7094
.then(files => files.filter(file => file.isFile() && file.name.endsWith('.html')))
7195
.then(files => files.map(file => path.resolve('dist', file.name)))
@@ -124,6 +148,11 @@ const injectHtmlPages = async (metafile, revision) => {
124148
}
125149
}
126150

151+
if (htmlContent.includes('<%= CONNECTION_HINTS %>')) {
152+
htmlContent = htmlContent.replace(/<%= CONNECTION_HINTS %>/g, hints)
153+
console.log(`Injected connection hints into ${path.relative(process.cwd(), htmlFilePath)}.`)
154+
}
155+
127156
if (!htmlContent.includes('<%= GIT_VERSION %>')) {
128157
// 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
129158
throw new Error(`${path.relative(process.cwd(), htmlFilePath)} does not contain <%= GIT_VERSION %>.`)

public/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<title>IPFS Service Worker Gateway | <%= GIT_VERSION %></title>
2424
<meta name="description" content="A static HTML page that initializes an instance of IPFS Service Worker Gateway" />
2525
<meta name="robots" content="noindex" />
26+
<%= CONNECTION_HINTS %>
2627
<link rel="icon" href="/ipfs-sw-favicon.ico" type="image/ico"/>
2728
<link rel="shortcut icon" href="/ipfs-sw-favicon.ico" type="image/x-icon"/>
2829
<%= CSS_STYLES %>

src/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { config } from './config/index.ts'
55
import { QUERY_PARAMS } from './lib/constants.ts'
66
import { parseRequest } from './lib/parse-request.ts'
77
import { isSafeOrigin } from './lib/path-or-subdomain.ts'
8+
import { prewarmContentRequest } from './lib/prewarm-content.ts'
89
import { registerServiceWorker } from './lib/register-service-worker.ts'
910
import type { ResolvableURI } from './lib/parse-request.ts'
1011

@@ -159,6 +160,8 @@ async function main (): Promise<void> {
159160
const hasActiveWorker = registration?.active != null
160161

161162
if (!hasActiveWorker) {
163+
prewarmContentRequest(request, config)
164+
162165
// install the service worker on the root path of this domain, either
163166
// path or subdomain gateway
164167
await registerServiceWorker()

src/lib/prewarm-content.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { base36 } from 'multiformats/bases/base36'
2+
import type { ResolvableURI } from './parse-request.ts'
3+
import type { Config } from '../config/index.ts'
4+
5+
const FILTER_ADDRS = 'wss,tls,https'
6+
const FILTER_PROTOCOLS = 'unknown,transport-bitswap,transport-ipfs-gateway-http'
7+
8+
function toArray (value: string | string[]): string[] {
9+
return Array.isArray(value) ? value : [value]
10+
}
11+
12+
function joinPath (basePathname: string, suffix: string): string {
13+
const base = basePathname.endsWith('/') ? basePathname.slice(0, -1) : basePathname
14+
15+
if (base.length === 0) {
16+
return suffix
17+
}
18+
19+
return `${base}${suffix}`
20+
}
21+
22+
function warmRequest (url: URL): void {
23+
void fetch(url.toString(), {
24+
method: 'GET',
25+
mode: 'cors',
26+
credentials: 'omit',
27+
cache: 'force-cache',
28+
keepalive: true
29+
}).catch(() => {
30+
// ignore prewarm errors, this is a best-effort optimization
31+
})
32+
}
33+
34+
export function buildPrewarmUrls (request: ResolvableURI, options: Pick<Config, 'routers' | 'dnsResolvers'>): URL[] {
35+
const urls: URL[] = []
36+
const routers = options.routers.map(router => new URL(router))
37+
const resolvers = Object.values(options.dnsResolvers)
38+
.flatMap(value => toArray(value))
39+
.map(resolver => new URL(resolver))
40+
41+
if (request.type === 'internal' || request.type === 'external') {
42+
return urls
43+
}
44+
45+
if (request.protocol === 'ipfs') {
46+
routers.forEach(router => {
47+
const url = new URL(router.toString())
48+
url.pathname = joinPath(url.pathname, `/routing/v1/providers/${request.cid.toString()}`)
49+
url.searchParams.set('filter-addrs', FILTER_ADDRS)
50+
url.searchParams.set('filter-protocols', FILTER_PROTOCOLS)
51+
urls.push(url)
52+
})
53+
}
54+
55+
if (request.protocol === 'ipns') {
56+
routers.forEach(router => {
57+
const url = new URL(router.toString())
58+
url.pathname = joinPath(url.pathname, `/routing/v1/ipns/${request.peerId.toCID().toString(base36)}`)
59+
urls.push(url)
60+
})
61+
}
62+
63+
if (request.protocol === 'dnslink') {
64+
resolvers.forEach(resolver => {
65+
const url = new URL(resolver.toString())
66+
url.searchParams.set('name', `_dnslink.${request.domain}`)
67+
url.searchParams.set('type', 'TXT')
68+
urls.push(url)
69+
})
70+
}
71+
72+
return urls
73+
}
74+
75+
export function prewarmContentRequest (request: ResolvableURI, options: Pick<Config, 'routers' | 'dnsResolvers'>): void {
76+
buildPrewarmUrls(request, options).forEach(url => {
77+
warmRequest(url)
78+
})
79+
}

test/lib/prewarm-content.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { expect } from 'aegir/chai'
2+
import { dnsLinkLabelEncoder } from '../../src/lib/dns-link-labels.ts'
3+
import { parseRequest } from '../../src/lib/parse-request.ts'
4+
import { buildPrewarmUrls } from '../../src/lib/prewarm-content.ts'
5+
6+
const options = {
7+
routers: ['https://delegated-ipfs.dev'],
8+
dnsResolvers: {
9+
'.': 'https://delegated-ipfs.dev/dns-query'
10+
}
11+
}
12+
13+
describe('prewarm-content', () => {
14+
it('builds provider query for ipfs requests', () => {
15+
const request = parseRequest(new URL('https://bafyaaaa.ipfs.localhost:3000/'), new URL('https://localhost:3000'))
16+
const urls = buildPrewarmUrls(request, options)
17+
18+
expect(urls).to.have.lengthOf(1)
19+
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')
20+
})
21+
22+
it('builds routing query for ipns requests', () => {
23+
const ipnsName = 'k51qzi5uqu5djpzsgwway43y9oc6p6zf2mco05x7yo6njcs9n1u4s19416t69w'
24+
const request = parseRequest(new URL(`https://${ipnsName}.ipns.localhost:3000/`), new URL('https://localhost:3000'))
25+
const urls = buildPrewarmUrls(request, options)
26+
27+
expect(urls).to.have.lengthOf(1)
28+
expect(urls[0].toString()).to.equal(`https://delegated-ipfs.dev/routing/v1/ipns/${ipnsName}`)
29+
})
30+
31+
it('builds dns query for dnslink requests', () => {
32+
const domain = 'docs.ipfs.tech'
33+
const request = parseRequest(new URL(`https://${dnsLinkLabelEncoder(domain)}.ipns.localhost:3000/`), new URL('https://localhost:3000'))
34+
const urls = buildPrewarmUrls(request, options)
35+
36+
expect(urls).to.have.lengthOf(1)
37+
expect(urls[0].toString()).to.equal('https://delegated-ipfs.dev/dns-query?name=_dnslink.docs.ipfs.tech&type=TXT')
38+
})
39+
})

0 commit comments

Comments
 (0)