nftstorage.link
Version:
Utilities for working with the NFT.Storage IPFS Edge Gateway
371 lines (333 loc) • 9.94 kB
JavaScript
/**
* A client library for the https://api.nftstorage.link/ perma-cache service. It provides a convenient
* interface for working with the [Raw HTTP API](https://nftstorage.link/#api-docs)
* from a web browser or [Node.js](https://nodejs.org/) and comes bundled with
* TS for out-of-the box type inference and better IntelliSense.
*
* @example
* ```js
* import { PermaCache } from 'nftstorage.link'
*
* const urls = [
* 'https://bafkreidyeivj7adnnac6ljvzj2e3rd5xdw3revw4da7mx2ckrstapoupoq.ipfs.nftstorage.link'
* ]
*
* const cache = new PermaCache({ token: 'YOUR_NFT_STORAGE_TOKEN' })
* const permaCacheEntries = await cache.put(urls)
* await cache.delete(urls)
* ```
* @module
*/
import throttledQueue from 'throttled-queue'
import pSettle from 'p-settle'
import pRetry, { AbortError } from 'p-retry'
import { fetch } from './platform.js'
const MAX_RETRIES = 5
// These match what is enforced server-side
const RATE_LIMIT_REQUESTS = 100
const RATE_LIMIT_PERIOD = 60 * 1000
/**
* @typedef { import('./lib/interface.js').RateLimiter } RateLimiter
* @typedef { import('./lib/interface.js').Service } Service
* @typedef { import('./lib/interface.js').PutOptions} PutOptions
* @typedef { import('./lib/interface.js').DeleteOptions} DeleteOptions
* @typedef { import('./lib/interface.js').ListOptions} ListOptions
* @typedef { import('./lib/interface.js').CacheResult} CacheResult
* @typedef { import('./lib/interface.js').CacheDeleteResult} CacheDeleteResult
* @typedef { import('./lib/interface.js').CacheEntry} CacheEntry
* @typedef { import('./lib/interface.js').AccountInfo} AccountInfo
*/
/**
* @implements Service
*/
export class PermaCache {
/**
* Constructs a client bound to the given `options.token` and
* `options.endpoint`.
*
* @example
* ```js
* import { PermaCache } from 'nftstorage.link'
* const cache = new PermaCache({ token: API_TOKEN })
* ```
*
* @param {{token: string, endpoint?:URL, rateLimiter?: RateLimiter}} options
*/
constructor({
token,
endpoint = new URL('https://api.nftstorage.link'),
rateLimiter,
}) {
/**
* Authorization token.
*
* @readonly
*/
this.token = token
/**
* Service API endpoint `URL`.
* @readonly
*/
this.endpoint = endpoint
/**
* @readonly
*/
this.rateLimiter = rateLimiter || createRateLimiter()
}
/**
* @hidden
* @param {string} token
* @returns {Record<string, string>}
*/
static headers(token) {
/* c8 ignore next 1 */
if (!token) throw new Error('missing token')
return {
Authorization: `Bearer ${token}`,
'X-Client': 'nftstorage.link/js',
}
}
/**
* @param {Service} service
* @param {string[]} urls
* @param {PutOptions} [options]
* @returns {Promise<CacheResult[]>}
*/
static async put(
{ endpoint, token, rateLimiter = globalRateLimiter },
urls,
{ onPut, maxRetries } = {}
) {
urls.forEach(validateUrl)
const headers = PermaCache.headers(token)
/** @type {import('p-settle').PromiseResult<CacheResult>[]} */
const cacheResults = await pSettle(
urls.map(async (url) => {
const apiUrl = new URL(
`perma-cache/${encodeURIComponent(url)}`,
endpoint
)
return await pRetry(
async () => {
await rateLimiter()
const response = await fetch(apiUrl.toString(), {
method: 'POST',
headers,
})
/** @type {CacheResult} */
const result = await response.json()
if (!response.ok) {
// @ts-ignore Only exists on Error
const e = new Error(result.message)
// do not retry if fatal errors - will not succeed
if (response.status >= 400 && response.status < 500) {
throw new AbortError(e)
}
/* c8 ignore next 2 */
throw e
}
onPut && onPut(url)
return result
},
/* c8 ignore next 3 */
{
retries: maxRetries == null ? MAX_RETRIES : maxRetries,
}
)
})
)
return cacheResults.map((r, i) => {
// @ts-ignore reason and value might not exist, but one of them always exists
return r.reason ? { url: urls[i], error: r.reason.message } : r.value
})
}
/**
* @param {Service} service
* @param {ListOptions} [options]
* @returns {AsyncIterable<CacheEntry>}
*/
static async *list(
{ endpoint, token, rateLimiter = globalRateLimiter },
{ sort = 'date', order = 'asc' } = {}
) {
const headers = PermaCache.headers(token)
let search = new URLSearchParams({ sort, order })
let nextPageUrl = new URL(`perma-cache?${search}`, endpoint)
while (true) {
await rateLimiter()
const response = await fetch(nextPageUrl.toString(), {
method: 'GET',
headers,
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.message)
}
for (const entry of result) {
yield entry
}
// Go over next links until not provided by API anymore
const link = response.headers.get('link')
if (!link) {
break
}
nextPageUrl = new URL(
link.replace('<', '').replace('>; rel="next"', ''),
endpoint
)
}
}
/**
* @param {Service} service
* @param {string[]} urls
* @param {DeleteOptions} [options]
* @returns {Promise<CacheDeleteResult[]>}
*/
static async delete(
{ endpoint, token, rateLimiter = globalRateLimiter },
urls,
{ onDelete, maxRetries } = {}
) {
urls.forEach(validateUrl)
const headers = PermaCache.headers(token)
/** @type {import('p-settle').PromiseResult<CacheDeleteResult>[]} */
const cacheDeleteResults = await pSettle(
urls.map(async (url) => {
const apiUrl = new URL(
`perma-cache/${encodeURIComponent(url)}`,
endpoint
)
return await pRetry(
async () => {
await rateLimiter()
const response = await fetch(apiUrl.toString(), {
method: 'DELETE',
headers,
})
const result = await response.json()
if (!response.ok) {
const e = new Error(result.message)
// do not retry if fatal errors - will not succeed
if (response.status >= 400 && response.status < 500) {
throw new AbortError(e)
}
/* c8 ignore next 2 */
throw e
}
onDelete && onDelete(url)
return {
url,
}
},
/* c8 ignore next 3 */
{
retries: maxRetries == null ? MAX_RETRIES : maxRetries,
}
)
})
)
return cacheDeleteResults.map((r, i) => {
// @ts-ignore reason and value might not exist, but one of them always exists
return r.reason ? { url: urls[i], error: r.reason.message } : r.value
})
}
/**
* @param {Service} service
* @return {Promise<AccountInfo>}
*/
static async accountInfo({
endpoint,
token,
rateLimiter = globalRateLimiter,
}) {
const url = new URL('perma-cache/account', endpoint)
const headers = PermaCache.headers(token)
await rateLimiter()
const response = await fetch(url.toString(), {
method: 'GET',
headers,
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.message)
}
return result
}
// Just a sugar so you don't have to pass around endpoint and token around.
/**
* Perma cache URLS into nftstorage.link.
*
* Returns the corresponding Perma cache entries created.
*
* @param {string[]} urls
* @param {PutOptions} [options]
* @returns {Promise<CacheResult[]>}
*/
put(urls, options) {
return PermaCache.put(this, urls, options)
}
/**
* List all Perma cached URLs for this account. Use a `for await...of` loop to fetch them all.
* @example
* Fetch all the urls
* ```js
* const urls = []
* for await (const item of client.list()) {
* urls.push(item)
* }
* ```
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of
* @param {ListOptions} [options]
* @returns {AsyncIterable<CacheEntry>}
*/
list(options) {
return PermaCache.list(this, options)
}
/**
* @param {string[]} urls
* @param {DeleteOptions} [options]
* @returns {Promise<CacheDeleteResult[]>}
*/
delete(urls, options) {
return PermaCache.delete(this, urls, options)
}
/**
* Fetch info on PermaCache for the user.
*/
accountInfo() {
return PermaCache.accountInfo(this)
}
}
/**
* Creates a rate limiter which limits at the same rate as is enforced
* server-side, to allow the client to avoid exceeding the requests limit and
* being blocked for 30 seconds.
* @returns {RateLimiter}
*/
export function createRateLimiter() {
const throttle = throttledQueue(RATE_LIMIT_REQUESTS, RATE_LIMIT_PERIOD)
return () => throttle(() => {})
}
/**
* Rate limiter used by static API if no rate limiter is passed. Note that each
* instance of the PermaCache class gets it's own limiter if none is passed.
* This is because rate limits are enforced per API token.
*/
const globalRateLimiter = createRateLimiter()
/**
* @param {string} urlString
*/
function validateUrl(urlString) {
const url = new URL(urlString)
if (
!url.hostname.includes('.ipfs.nftstorage.link') &&
!(
url.hostname.includes('nftstorage.link') &&
url.pathname.startsWith('/ipfs')
)
) {
throw new Error(
`Invalid URL (not an nftstorage.link IPFS URL): ${urlString}`
)
}
}