Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement new ipns record&answer properties #23

Merged
merged 9 commits into from
Mar 18, 2024
5 changes: 3 additions & 2 deletions packages/verified-fetch/src/utils/parse-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ export async function parseResource (resource: Resource, { ipns, logger }: Parse
cid,
protocol: 'ipfs',
path: '',
query: {}
}
query: {},
ttl: 29030400 // 1 year for ipfs content
} satisfies ParsedUrlStringResults
}

throw new TypeError(`Invalid resource. Cannot determine CID from resource: ${resource}`)
Expand Down
49 changes: 36 additions & 13 deletions packages/verified-fetch/src/utils/parse-url-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { peerIdFromString } from '@libp2p/peer-id'
import { CID } from 'multiformats/cid'
import { TLRU } from './tlru.js'
import type { RequestFormatShorthand } from '../types.js'
import type { IPNS, ResolveDNSLinkProgressEvents, ResolveResult } from '@helia/ipns'
import type { DNSLinkResolveResult, IPNS, IPNSResolveResult, ResolveDNSLinkProgressEvents, ResolveResult } from '@helia/ipns'
import type { ComponentLogger } from '@libp2p/interface'
import type { ProgressOptions } from 'progress-events'

const ipnsCache = new TLRU<ResolveResult>(1000)
const ipnsCache = new TLRU<DNSLinkResolveResult | IPNSResolveResult>(1000)

export interface ParseUrlStringInput {
urlString: string
Expand All @@ -23,24 +23,32 @@ export interface ParsedUrlQuery extends Record<string, string | unknown> {
filename?: string
}

export interface ParsedUrlStringResults {
protocol: string
path: string
cid: CID
interface ParsedUrlStringResultsBase extends ResolveResult {
protocol: 'ipfs' | 'ipns'
query: ParsedUrlQuery
ttl: number
}

export type ParsedUrlStringResults = ParsedUrlStringResultsBase // | DNSLinkResolveResult | IPNSResolveResult
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved

const URL_REGEX = /^(?<protocol>ip[fn]s):\/\/(?<cidOrPeerIdOrDnsLink>[^/?]+)\/?(?<path>[^?]*)\??(?<queryString>.*)$/
const PATH_REGEX = /^\/(?<protocol>ip[fn]s)\/(?<cidOrPeerIdOrDnsLink>[^/?]+)\/?(?<path>[^?]*)\??(?<queryString>.*)$/
const PATH_GATEWAY_REGEX = /^https?:\/\/(.*[^/])\/(?<protocol>ip[fn]s)\/(?<cidOrPeerIdOrDnsLink>[^/?]+)\/?(?<path>[^?]*)\??(?<queryString>.*)$/
const SUBDOMAIN_GATEWAY_REGEX = /^https?:\/\/(?<cidOrPeerIdOrDnsLink>[^/?]+)\.(?<protocol>ip[fn]s)\.([^/?]+)\/?(?<path>[^?]*)\??(?<queryString>.*)$/

function matchURLString (urlString: string): Record<string, string> {
interface MatchUrlGroups {
protocol: 'ipfs' | 'ipns'
cidOrPeerIdOrDnsLink: string
path?: string
queryString?: string

}
function matchURLString (urlString: string): MatchUrlGroups {
for (const pattern of [URL_REGEX, PATH_REGEX, PATH_GATEWAY_REGEX, SUBDOMAIN_GATEWAY_REGEX]) {
const match = urlString.match(pattern)

if (match?.groups != null) {
return match.groups
return match.groups as unknown as MatchUrlGroups // force cast to MatchUrlGroups, because if it matches, it has to contain this structure.
}
}

Expand Down Expand Up @@ -89,30 +97,43 @@ export async function parseUrlString ({ urlString, ipns, logger }: ParseUrlStrin
let cid: CID | undefined
let resolvedPath: string | undefined
const errors: Error[] = []
let resolveResult: IPNSResolveResult | DNSLinkResolveResult | undefined
let ttl: number = 5 * 60 // 5 minutes based on https://github.com/ipfs/boxo/issues/329#issuecomment-1995236409
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved

if (protocol === 'ipfs') {
try {
cid = CID.parse(cidOrPeerIdOrDnsLink)
ttl = 29030400 // 1 year for ipfs content
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
} catch (err) {
log.error(err)
errors.push(new TypeError('Invalid CID for ipfs://<cid> URL'))
}
} else {
let resolveResult = ipnsCache.get(cidOrPeerIdOrDnsLink)
// protocol is ipns
resolveResult = ipnsCache.get(cidOrPeerIdOrDnsLink)

if (resolveResult != null) {
cid = resolveResult.cid
resolvedPath = resolveResult.path
const answerTtl = (resolveResult as DNSLinkResolveResult).answer?.TTL
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
const recordTtl = (resolveResult as IPNSResolveResult).record?.ttl
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
ttl = Number(answerTtl ?? recordTtl ?? ttl)
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
log.trace('resolved %s to %c from cache', cidOrPeerIdOrDnsLink, cid)
} else {
// protocol is ipns
log.trace('Attempting to resolve PeerId for %s', cidOrPeerIdOrDnsLink)
let peerId = null
try {
peerId = peerIdFromString(cidOrPeerIdOrDnsLink)
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
resolveResult = await ipns.resolve(peerId, { onProgress: options?.onProgress })
cid = resolveResult?.cid
resolvedPath = resolveResult?.path
/**
* For TTL nuances, see
*
* @see https://github.com/ipfs/js-ipns/blob/16e0e10682fa9a663e0bb493a44d3e99a5200944/src/index.ts#L200
* @see https://github.com/ipfs/js-ipns/pull/308
*/
ttl = Number(resolveResult.record.ttl)
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
log.trace('resolved %s to %c', cidOrPeerIdOrDnsLink, cid)
ipnsCache.set(cidOrPeerIdOrDnsLink, resolveResult, 60 * 1000 * 2)
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
} catch (err) {
Expand All @@ -137,6 +158,7 @@ export async function parseUrlString ({ urlString, ipns, logger }: ParseUrlStrin
resolveResult = await ipns.resolveDNSLink(decodedDnsLinkLabel, { onProgress: options?.onProgress })
cid = resolveResult?.cid
resolvedPath = resolveResult?.path
ttl = resolveResult.answer.TTL
log.trace('resolved %s to %c', decodedDnsLinkLabel, cid)
ipnsCache.set(cidOrPeerIdOrDnsLink, resolveResult, 60 * 1000 * 2)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use the record's TTL value also for the TLRU cache (instead of the static 2 minute value we set).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@multiformats/dns caches all query answers up to the answer TTL, and @helia/ipns stores resolved IPNS records in the local datastore, so is it necessary to have another TLRU cache at this level?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not. What caching logic is there for IPNS? Does it always return from cache if the record is still valid?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can test removing this but even though helia/ipns was supposed to be caching records previously, it wasnt, and multiple requests were seen in sw gateway

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may have been because up until recently we were reinstantiating verified fetch for every request to the SW

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah thats a good point...

Copy link
Member

@achingbrain achingbrain Mar 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What caching logic is there for IPNS? Does it always return from cache if the record is still valid?

It's here: https://github.com/ipfs/helia/blob/main/packages/ipns/src/index.ts#L526-L584

It searches the local datastore for a cached record but also searches the routing, so we might just need to make it only go to the routing if the local store doesn't have a valid record.

The problem there is you won't see an updated IPNS record until your local copy expires, or the user requests an uncached record.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm going to use the existing cache for now, but set the TTL for the tlru cache to ttl ?? 60 * 1000 * 2

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated caching logic for @helia/ipns - ipfs/helia#473

} catch (err: any) {
Expand Down Expand Up @@ -177,9 +199,10 @@ export async function parseUrlString ({ urlString, ipns, logger }: ParseUrlStrin
return {
protocol,
cid,
path: joinPaths(resolvedPath, urlPath),
query
}
path: joinPaths(resolvedPath, urlPath ?? ''),
query,
ttl
} satisfies ParsedUrlStringResults
}

/**
Expand Down
31 changes: 31 additions & 0 deletions packages/verified-fetch/src/utils/response-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
interface CacheControlHeaderOptions {
ttl?: number
protocol: 'ipfs' | 'ipns'
response: Response
}
/**
* Implementations may place an upper bound on any TTL received, as noted in Section 8 of [rfc2181].
* If TTL value is unknown, implementations should not send a Cache-Control
* No matter if TTL value is known or not, implementations should always send a Last-Modified header with the timestamp of the record resolution.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*
* @see https://specs.ipfs.tech/http-gateways/path-gateway/#cache-control-response-header
*/
export function setCacheControlHeader ({ ttl, protocol, response }: CacheControlHeaderOptions): void {
let headerValue: string
if (protocol === 'ipfs') {
headerValue = 'public, max-age=29030400, immutable'
} else if (ttl == null) {
/**
* default limit for unknown TTL: "use 5 minute as default fallback when it is not available."
*
* @see https://github.com/ipfs/boxo/issues/329#issuecomment-1995236409
*/
headerValue = 'public, max-age=300'

Check warning on line 23 in packages/verified-fetch/src/utils/response-headers.ts

View check run for this annotation

Codecov / codecov/patch

packages/verified-fetch/src/utils/response-headers.ts#L18-L23

Added lines #L18 - L23 were not covered by tests
} else {
headerValue = `public, max-age=${ttl}`
}

if (headerValue != null) {
response.headers.set('cache-control', headerValue)
}
}
24 changes: 20 additions & 4 deletions packages/verified-fetch/src/verified-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterable.js'
import { tarStream } from './utils/get-tar-stream.js'
import { parseResource } from './utils/parse-resource.js'
import { setCacheControlHeader } from './utils/response-headers.js'
import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse } from './utils/responses.js'
import { selectOutputType, queryFormatToAcceptHeader } from './utils/select-output-type.js'
import { walkPath } from './utils/walk-path.js'
import type { CIDDetail, ContentTypeParser, Resource, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
import type { RequestFormatShorthand } from './types.js'
import type { ParsedUrlStringResults } from './utils/parse-url-string'
import type { Helia } from '@helia/interface'
import type { AbortOptions, Logger, PeerId } from '@libp2p/interface'
import type { DNSResolver } from '@multiformats/dns/resolvers'
Expand Down Expand Up @@ -408,7 +410,23 @@
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { resource }))

// resolve the CID/path from the requested resource
const { path, query, cid, protocol } = await parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, options)
let cid: ParsedUrlStringResults['cid']
let path: ParsedUrlStringResults['path']
let query: ParsedUrlStringResults['query']
let ttl: ParsedUrlStringResults['ttl']
let protocol: ParsedUrlStringResults['protocol']
try {
const result = await parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, options)
cid = result.cid
path = result.path
query = result.query
ttl = result.ttl
protocol = result.protocol
} catch (err) {
this.log.error('error parsing resource %s', resource, err)

return badRequestResponse('Invalid resource')
}

Check warning on line 429 in packages/verified-fetch/src/verified-fetch.ts

View check run for this annotation

Codecov / codecov/patch

packages/verified-fetch/src/verified-fetch.ts#L426-L429

Added lines #L426 - L429 were not covered by tests

options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:resolve', { cid, path }))

Expand Down Expand Up @@ -473,9 +491,7 @@

response.headers.set('etag', getETag({ cid, reqFormat, weak: false }))

if (protocol === 'ipfs') {
response.headers.set('cache-control', 'public, max-age=29030400, immutable')
}
setCacheControlHeader({ response, ttl, protocol })
// https://specs.ipfs.tech/http-gateways/path-gateway/#x-ipfs-path-response-header
response.headers.set('X-Ipfs-Path', resource.toString())

Expand Down
46 changes: 36 additions & 10 deletions packages/verified-fetch/test/cache-control-header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,41 @@ import { dagCbor } from '@helia/dag-cbor'
import { ipns } from '@helia/ipns'
import { stop } from '@libp2p/interface'
import { createEd25519PeerId } from '@libp2p/peer-id-factory'
import { dns } from '@multiformats/dns'
import { expect } from 'aegir/chai'
import Sinon from 'sinon'
import { stubInterface } from 'sinon-ts'
import { VerifiedFetch } from '../src/verified-fetch.js'
import { createHelia } from './fixtures/create-offline-helia.js'
import type { Helia } from '@helia/interface'
import type { IPNS } from '@helia/ipns'

import type { DNSResponse } from '@multiformats/dns'

function answerFake (data: string, TTL: number, name: string, type: number): DNSResponse {
const fake = stubInterface<DNSResponse>()
fake.Answer = [{
data,
TTL,
name,
type
}]
return fake
}
describe('cache-control header', () => {
let helia: Helia
let name: IPNS
let verifiedFetch: VerifiedFetch
let customDnsResolver: Sinon.SinonStub<any[], Promise<DNSResponse>>

beforeEach(async () => {
helia = await createHelia()
customDnsResolver = Sinon.stub()
helia = await createHelia({
dns: dns({
resolvers: {
'.': customDnsResolver
}
})
})
name = ipns(helia)
verifiedFetch = new VerifiedFetch({
helia
Expand Down Expand Up @@ -60,7 +81,7 @@ describe('cache-control header', () => {
expect(resp.headers.get('Cache-Control')).to.not.containIgnoreCase('immutable')
})

it.skip('should return the correct max-age in the cache-control header for an IPNS name', async () => {
it('should return the correct max-age in the cache-control header for an IPNS name', async () => {
const obj = {
hello: 'world'
}
Expand All @@ -70,19 +91,24 @@ describe('cache-control header', () => {
const oneHourInMs = 1000 * 60 * 60
const peerId = await createEd25519PeerId()

// ipns currently only allows customising the lifetime which is also used as the TTL
await name.publish(peerId, cid, { lifetime: oneHourInMs })
/**
* ipns currently only allows customising the lifetime which is also used as the TTL
*
* lifetime is coming back as 100000 times larger than expected
*
* @see https://github.com/ipfs/js-ipns/blob/16e0e10682fa9a663e0bb493a44d3e99a5200944/src/index.ts#L200
* @see https://github.com/ipfs/js-ipns/pull/308
*/
await name.publish(peerId, cid, { lifetime: oneHourInMs / 100000 })
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved

const resp = await verifiedFetch.fetch(`ipns://${peerId}`)
expect(resp).to.be.ok()
expect(resp.status).to.equal(200)

expect(resp.headers.get('Cache-Control')).to.equal(`public, max-age=${oneHourInMs.toString()}`)
expect(resp.headers.get('Cache-Control')).to.equal(`public, max-age=${oneHourInMs}`)
})

it('should not contain immutable in the cache-control header for a DNSLink name', async () => {
const customDnsResolver = Sinon.stub()

verifiedFetch = new VerifiedFetch({
helia
}, {
Expand All @@ -94,12 +120,12 @@ describe('cache-control header', () => {
}
const c = dagCbor(helia)
const cid = await c.add(obj)
customDnsResolver.returns(Promise.resolve(`/ipfs/${cid.toString()}`))
customDnsResolver.withArgs('_dnslink.example-domain.com').resolves(answerFake(`dnslink=/ipfs/${cid}`, 666, '_dnslink.example-domain.com', 16))

const resp = await verifiedFetch.fetch('ipns://example-domain.com')
expect(resp).to.be.ok()
expect(resp.status).to.equal(200)

expect(resp.headers.get('Cache-Control')).to.not.containIgnoreCase('immutable')
expect(resp.headers.get('Cache-Control')).to.equal('public, max-age=666')
})
})
8 changes: 5 additions & 3 deletions packages/verified-fetch/test/custom-dns-resolvers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ describe('custom dns-resolvers', () => {
})

it('is used when passed to createVerifiedFetch', async () => {
const customDnsResolver = Sinon.stub()

customDnsResolver.returns(Promise.resolve('/ipfs/QmVP2ip92jQuMDezVSzQBWDqWFbp9nyCHNQSiciRauPLDg'))
const customDnsResolver = Sinon.stub().withArgs('_dnslink.some-non-cached-domain.com').resolves({
Answer: [{
data: 'dnslink=/ipfs/QmVP2ip92jQuMDezVSzQBWDqWFbp9nyCHNQSiciRauPLDg'
}]
})

const fetch = await createVerifiedFetch({
gateways: ['http://127.0.0.1:8080'],
Expand Down
5 changes: 3 additions & 2 deletions packages/verified-fetch/test/utils/parse-url-string.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ describe('parseUrlString', () => {
ipns,
logger
})
).to.eventually.be.rejected
.with.property('message', 'Could not parse PeerId in ipns url "mydomain.com", Non-base64 character')
).to.eventually.be.rejected.and.to.have.nested.property('errors[0]').to.deep.equal(
new TypeError('Could not parse PeerId in ipns url "mydomain.com", Non-base64 character')
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
).to.eventually.be.rejected.and.to.have.nested.property('errors[0]').to.deep.equal(
new TypeError('Could not parse PeerId in ipns url "mydomain.com", Non-base64 character')
)
).to.eventually.be.rejected
.with.nested.property('errors[0].message', 'Could not parse PeerId in ipns url "mydomain.com", Non-base64 character')

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fails

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the error you are seeing? It passes for me..

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you know what, i read this on my phone. looking from the PC, this is probably fine. will test.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk what happened but reverting it back to the original .to.eventually.be.rejected.with.property('message', 'Could not parse PeerId in ipns url "mydomain.com", Non-base64 character') works.

})
})

Expand Down
Loading