Skip to content

Commit

Permalink
feat: add options to configure IPFS gateway to use to fetch assets (#11)
Browse files Browse the repository at this point in the history
BREAKING CHANGE:
The `CacheOnlyIPFS` constructor has breaking changes to allow specifying which IPFS gateway to use to fetch assets.
  • Loading branch information
mderriey authored Nov 19, 2024
1 parent 9fde561 commit ce3051b
Show file tree
Hide file tree
Showing 7 changed files with 7,688 additions and 5,314 deletions.
3 changes: 0 additions & 3 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm run commitlint
12,859 changes: 7,588 additions & 5,271 deletions package-lock.json

Large diffs are not rendered by default.

48 changes: 24 additions & 24 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,41 +48,41 @@
},
"dependencies": {
"async-retry": "^1.3.3",
"blockstore-core": "^4.3.3",
"ipfs-unixfs-importer": "^15.1.7",
"blockstore-core": "^5.0.2",
"ipfs-unixfs-importer": "^15.3.1",
"multiformats": "^9.9.0"
},
"peerDependencies": {
"@makerx/node-cache": "^1.0.2"
"@makerx/node-cache": "^1.1.0"
},
"devDependencies": {
"@commitlint/cli": "^18.4.3",
"@commitlint/config-conventional": "^18.4.3",
"@makerx/eslint-config": "^3.1.0",
"@makerx/prettier-config": "^2.0.0",
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
"@makerx/eslint-config": "^3.1.1",
"@makerx/prettier-config": "^2.0.1",
"@makerx/ts-config": "^1.0.1",
"@types/async-retry": "^1.4.8",
"@types/jest": "^29.5.11",
"@types/node": "^18.13.0",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"conventional-changelog-conventionalcommits": "^7.0.2",
"eslint": "^8.56.0",
"@types/async-retry": "^1.4.9",
"@types/jest": "^29.5.14",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.14.0",
"@typescript-eslint/parser": "^8.14.0",
"conventional-changelog-conventionalcommits": "^8.0.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.1.1",
"husky": "^8.0.3",
"eslint-plugin-prettier": "^5.2.1",
"husky": "^9.1.6",
"jest": "^29.7.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.1.1",
"rimraf": "^5.0.5",
"semantic-release": "^22.0.12",
"ts-jest": "^29.1.1",
"ts-jest-mocker": "^0.5.0",
"prettier": "^3.3.3",
"rimraf": "^6.0.1",
"semantic-release": "^24.2.0",
"ts-jest": "^29.2.5",
"ts-jest-mocker": "^1.0.0",
"ts-node": "^10.9.2",
"typedoc": "^0.25.4",
"typedoc-plugin-markdown": "^3.17.1",
"typescript": "^5.3.3"
"typedoc": "^0.26.11",
"typedoc-plugin-markdown": "^4.2.10",
"typescript": "^5.6.3"
},
"release": {
"branches": [
Expand Down
2 changes: 1 addition & 1 deletion src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async function retryer(url: string, fetchOptions?: RequestInit, retries: number
retries: retries,
onRetry: (e, num) => {
// eslint-disable-next-line no-console
console.debug(`HTTP request failed. Retrying for #${num} time: ${e.message}`)
console.debug(`HTTP request failed. Retrying for #${num} time: ${(e as Error).message}`)
},
},
)
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export * from './ipfs'
export { CacheOnlyIPFS, IPFS, InMemoryIPFS, PinataStorage, PinataStorageWithCache } from './ipfs'
37 changes: 35 additions & 2 deletions src/ipfs.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PinataStorageWithCache } from './ipfs'
import { getCIDUrl, PinataStorageWithCache } from './ipfs'
import { ObjectCache } from '@makerx/node-cache'
import { mock, Mock } from 'ts-jest-mocker'

Expand All @@ -16,7 +16,11 @@ describe('PinataStorageWithCache', () => {

beforeEach(() => {
cache = mock<ObjectCache>()
ipfs = new PinataStorageWithCache(testToken, cache)
cache.put.mockReturnValue(Promise.resolve())

ipfs = new PinataStorageWithCache(testToken, cache, {
baseUrl: 'https://dummy-ipfs-gateway.com',
})
fetch.mockReset()
fetch.mockResolvedValueOnce({
ok: true,
Expand Down Expand Up @@ -98,3 +102,32 @@ describe('PinataStorageWithCache', () => {
})
})
})

describe('getCIDUrl', () => {
it('correctly constructs the absolute URL for a CID', () => {
const baseUrl = 'https://ipfs.pinata.cloud/ipfs/'
const cid = 'my-cid'

const value = getCIDUrl(baseUrl, cid)

expect(value).toBe('https://ipfs.pinata.cloud/ipfs/my-cid')
})

it(`correctly constructs the absolute URL for a CID if the base URL doesn't have a trailing slash`, () => {
const baseUrl = 'https://ipfs.pinata.cloud/ipfs'
const cid = 'my-cid'

const value = getCIDUrl(baseUrl, cid)

expect(value).toBe('https://ipfs.pinata.cloud/ipfs/my-cid')
})

it('correctly constructs the absolute URL for a CID if the base URL has no path', () => {
const baseUrl = 'https://ipfs.pinata.cloud'
const cid = 'my-cid'

const value = getCIDUrl(baseUrl, cid)

expect(value).toBe('https://ipfs.pinata.cloud/my-cid')
})
})
51 changes: 39 additions & 12 deletions src/ipfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,21 +68,21 @@ export class InMemoryIPFS implements IPFS {

export class CacheOnlyIPFS implements IPFS {
private cache: ObjectCache
private getFromIpfs: boolean
private ipfsGatewayOptions?: IPFSGatewayOptions

constructor(cache: ObjectCache, getFromIpfs?: boolean) {
constructor(cache: ObjectCache, ipfsGatewayOptions?: IPFSGatewayOptions) {
this.cache = cache
this.getFromIpfs = getFromIpfs ?? false
this.ipfsGatewayOptions = ipfsGatewayOptions
}

async get<T>(cid: string): Promise<T> {
return await this.cache.getAndCache<T>(
`ipfs-${cid}`,
async (_e) => {
if (!this.getFromIpfs) {
if (!this.ipfsGatewayOptions) {
throw new Error('404')
}
const response = await fetchWithRetry(`https://${cid}.ipfs.cf-ipfs.com/`)
const response = await fetchWithRetry(getCIDUrl(this.ipfsGatewayOptions.baseUrl, cid))
const json = await response.json()
return json as T
},
Expand Down Expand Up @@ -112,10 +112,10 @@ export class CacheOnlyIPFS implements IPFS {
return await this.cache.getAndCache<Uint8Array>(
`ipfs-${cid}`,
async (_e) => {
if (!this.getFromIpfs) {
if (!this.ipfsGatewayOptions) {
throw new Error('404')
}
const response = await fetchWithRetry(`https://${cid}.ipfs.cf-ipfs.com/`)
const response = await fetchWithRetry(getCIDUrl(this.ipfsGatewayOptions.baseUrl, cid))
return Buffer.from(await response.arrayBuffer())
},
{
Expand Down Expand Up @@ -160,24 +160,40 @@ type PinataMetadata = {
[key: string]: string | undefined
}

type IPFSGatewayOptions = {
/**
* The base URL to use when fetching assets from the IPFS gateway.
*
* The expected format is such that the URL should be correct if another URL segment with the CID is appended.
* @example `https://ipfs.algonode.dev/ipfs`
*/
baseUrl: string
}

const defaultIPFSGatewayOptions: IPFSGatewayOptions = {
baseUrl: 'https://ipfs.algonode.dev/ipfs',
}

export class PinataStorageWithCache implements IPFS {
private cache: ObjectCache
private token: string
private ipfsGatewayOptions: IPFSGatewayOptions
private pinataBaseUrl = 'https://api.pinata.cloud/pinning'

// We've chosen to use the Pinata API directly rather than their JS SDK,
// as it currently uses a really old version of axios, that has security vulnerabilities.

constructor(pinataToken: string, cache: ObjectCache) {
constructor(pinataToken: string, cache: ObjectCache, ipfsGatewayOptions?: IPFSGatewayOptions) {
this.token = pinataToken
this.cache = cache
this.ipfsGatewayOptions = ipfsGatewayOptions ?? defaultIPFSGatewayOptions
}

async get<T>(cid: string): Promise<T> {
return await this.cache.getAndCache<T>(
`ipfs-${cid}`,
async (_e) => {
const response = await fetchWithRetry(`https://${cid}.ipfs.cf-ipfs.com/`)
const response = await fetchWithRetry(getCIDUrl(this.ipfsGatewayOptions.baseUrl, cid))
// eslint-disable-next-line no-console
console.debug(`Cache miss for ${cid}, fetching from IPFS`)
const json = await response.json()
Expand Down Expand Up @@ -225,7 +241,7 @@ export class PinataStorageWithCache implements IPFS {
return await this.cache.getAndCache<Uint8Array>(
`ipfs-${cid}`,
async (_e) => {
const response = await fetchWithRetry(`https://${cid}.ipfs.cf-ipfs.com/`)
const response = await fetchWithRetry(getCIDUrl(this.ipfsGatewayOptions.baseUrl, cid))
// eslint-disable-next-line no-console
console.debug(`Cache miss for ${cid}, fetching from IPFS`)
return Buffer.from(await response.arrayBuffer())
Expand Down Expand Up @@ -330,10 +346,21 @@ class NoOpCache implements ObjectCache {
putBinary(_cacheKey: string, _data: Uint8Array, _mimeType?: string | undefined): Promise<void> {
return Promise.resolve()
}
clearCache(_cacheKey: string): void {}
}

export class PinataStorage extends PinataStorageWithCache {
constructor(pinataToken: string) {
super(pinataToken, new NoOpCache())
constructor(pinataToken: string, ipfsGatewayOptions?: IPFSGatewayOptions) {
super(pinataToken, new NoOpCache(), ipfsGatewayOptions)
}
}

export function getCIDUrl(ipfsGatewayBaseUrl: string, cid: string) {
// The trailing slash is important as it allows us to append the CID segment
// using URL instead of concatenating strings ourselves.
// Without the trailing slash, the whole path would be replaced.
const baseUrl = ipfsGatewayBaseUrl.endsWith('/') ? new URL(ipfsGatewayBaseUrl) : new URL(`${ipfsGatewayBaseUrl}/`)
const cidUrl = new URL(cid, baseUrl)

return cidUrl.toString()
}

0 comments on commit ce3051b

Please sign in to comment.