nft.storage
Version:
A client library for the https://nft.storage/ service. It provides a convenient interface for working with the HTTP API from a web browser or Node.js
405 lines (373 loc) • 12 kB
JavaScript
import { pack } from 'ipfs-car/pack'
import { CID } from 'multiformats/cid'
import * as Block from 'multiformats/block'
import { sha256 } from 'multiformats/hashes/sha2'
import * as dagCbor from '@ipld/dag-cbor'
import { Blob, FormData, Blockstore } from './platform.js'
import { toGatewayURL, GATEWAY } from './gateway.js'
import { BlockstoreCarReader } from './bs-car-reader.js'
/**
* @typedef {import('./gateway.js').GatewayURLOptions} EmbedOptions
* @typedef {import('./lib/interface.js').TokenInput} TokenInput
* @typedef {import('ipfs-car/blockstore').Blockstore} Blockstore
*/
/**
* @template T
* @typedef {import('./lib/interface.js').Encoded<T, [[Blob, URL]]>} EncodedBlobUrl
*/
/**
* @template G
* @typedef {import('./lib/interface.js').Encoded<G, [[Blob, Blob]]>} EncodedBlobBlob
*/
/**
* @template {import('./lib/interface.js').TokenInput} T
* @typedef {import('./lib/interface.js').Token<T>} TokenType
*/
/**
* @template {TokenInput} T
* @implements {TokenType<T>}
*/
export class Token {
/**
* @param {import('./lib/interface.js').CIDString} ipnft
* @param {import('./lib/interface.js').EncodedURL} url
* @param {import('./lib/interface.js').Encoded<T, [[Blob, URL]]>} data
*/
constructor(ipnft, url, data) {
/** @readonly */
this.ipnft = ipnft
/** @readonly */
this.url = url
/** @readonly */
this.data = data
Object.defineProperties(this, {
ipnft: { enumerable: true, writable: false },
url: { enumerable: true, writable: false },
data: { enumerable: false, writable: false },
})
}
/**
* @returns {import('./lib/interface.js').Encoded<T, [[Blob, URL]]>}
*/
embed() {
return Token.embed(this)
}
/**
* @template {TokenInput} T
* @param {{data: import('./lib/interface.js').Encoded<T, [[Blob, URL]]>}} token
* @returns {import('./lib/interface.js').Encoded<T, [[Blob, URL]]>}
*/
static embed({ data }) {
return embed(data, { gateway: GATEWAY })
}
/**
* Takes token input, encodes it as a DAG, wraps it in a CAR and creates a new
* Token instance from it. Where values are discovered `Blob` (or `File`)
* objects in the given input, they are replaced with IPFS URLs (an `ipfs://`
* prefixed CID with an optional path).
*
* @example
* ```js
* const cat = new File(['...'], 'cat.png')
* const kitty = new File(['...'], 'kitty.png')
* const { token, car } = await Token.encode({
* name: 'hello'
* image: cat
* properties: {
* extra: {
* image: kitty
* }
* }
* })
* ```
*
* @template {TokenInput} T
* @param {T} input
* @returns {Promise<{ cid: CID, token: TokenType<T>, car: import('./lib/interface.js').CarReader }>}
*/
static async encode(input) {
const blockstore = new Blockstore()
const [blobs, meta] = mapTokenInputBlobs(input)
/** @type {EncodedBlobUrl<T>} */
const data = JSON.parse(JSON.stringify(meta))
/** @type {import('./lib/interface.js').Encoded<T, [[Blob, CID]]>} */
const dag = JSON.parse(JSON.stringify(meta))
for (const [dotPath, blob] of blobs.entries()) {
/** @type {string|undefined} */
// @ts-ignore blob may be a File!
const name = blob.name || 'blob'
/** @type {import('./platform.js').ReadableStream} */
let content
// FIXME: should not be necessary to await arrayBuffer()!
// Node.js 20 hangs reading the stream (it never ends) but in
// older node versions and the browser it is fine to use blob.stream().
/* c8 ignore next 5 */
if (parseInt(globalThis.process?.versions?.node) > 18) {
content = new Uint8Array(await blob.arrayBuffer())
} else {
content = blob.stream()
}
const { root: cid } = await pack({
input: [{ path: name, content }],
blockstore,
wrapWithDirectory: true,
})
const href = new URL(`ipfs://${cid}/${name}`)
const path = dotPath.split('.')
setIn(data, path, href)
setIn(dag, path, cid)
}
const { root: metadataJsonCid } = await pack({
input: [{ path: 'metadata.json', content: JSON.stringify(data) }],
blockstore,
wrapWithDirectory: false,
})
const block = await Block.encode({
value: {
...dag,
'metadata.json': metadataJsonCid,
type: 'nft',
},
codec: dagCbor,
hasher: sha256,
})
await blockstore.put(block.cid, block.bytes)
return {
cid: block.cid,
token: new Token(
block.cid.toString(),
`ipfs://${block.cid}/metadata.json`,
data
),
car: new BlockstoreCarReader(1, [block.cid], blockstore),
}
}
}
/**
* @template T
* @param {EncodedBlobUrl<T>} input
* @param {EmbedOptions} options
* @returns {EncodedBlobUrl<T>}
*/
export const embed = (input, options) =>
mapWith(input, isURL, embedURL, options)
/**
* @template {TokenInput} T
* @param {import('./lib/interface.js').EncodedToken<T>} value
* @param {Set<string>} paths - Paths were to expect EncodedURLs
* @returns {Token<T>}
*/
export const decode = ({ ipnft, url, data }, paths) =>
new Token(ipnft, url, mapWith(data, isEncodedURL, decodeURL, paths))
/**
* @param {any} value
* @returns {value is URL}
*/
const isURL = (value) => value instanceof URL
/**
* @template State
* @param {State} state
* @param {import('./lib/interface.js').EncodedURL} url
* @returns {[State, URL]}
*/
const decodeURL = (state, url) => [state, new URL(url)]
/**
* @param {EmbedOptions} context
* @param {URL} url
* @returns {[EmbedOptions, URL]}
*/
const embedURL = (context, url) => [context, toGatewayURL(url, context)]
/**
* @param {any} value
* @returns {value is object}
*/
const isObject = (value) => typeof value === 'object' && value != null
/**
* @param {any} value
* @param {Set<string>} assetPaths
* @param {PropertyKey[]} path
* @returns {value is import('./lib/interface.js').EncodedURL}
*/
const isEncodedURL = (value, assetPaths, path) =>
typeof value === 'string' && assetPaths.has(path.join('.'))
/**
* Takes token input and encodes it into
* [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
* object where form field values are discovered `Blob` (or `File`) objects in
* the given token and field keys are `.` joined paths where they were discoverd
* in the token. Additionally encoded `FormData` will also have a field
* named `meta` containing JSON serialized token with blobs and file values
* `null` set to null (this allows backend to injest all of the files from
* `multipart/form-data` request and update provided "meta" data with
* corresponding file ipfs:// URLs)
*
* @example
* ```js
* const cat = new File([], 'cat.png')
* const kitty = new File([], 'kitty.png')
* const form = encode({
* name: 'hello'
* image: cat
* properties: {
* extra: {
* image: kitty
* }
* }
* })
* [...form.entries()] //>
* // [
* // ['image', cat],
* // ['properties.extra.image', kitty],
* // ['meta', '{"name":"hello",image:null,"properties":{"extra":{"kitty": null}}}']
* // ]
* ```
*
* @template {TokenInput} T
* @param {EncodedBlobBlob<T>} input
* @returns {FormData}
*/
export const encode = (input) => {
const [map, meta] = mapValueWith(input, isBlob, encodeBlob, new Map(), [])
const form = new FormData()
for (const [k, v] of map.entries()) {
form.set(k, v)
}
form.set('meta', JSON.stringify(meta))
return form
}
/**
* @param {Map<string, Blob>} data
* @param {Blob} blob
* @param {PropertyKey[]} path
* @returns {[Map<string, Blob>, void]}
*/
const encodeBlob = (data, blob, path) => {
data.set(path.join('.'), blob)
return [data, undefined]
}
/**
* @param {any} value
* @returns {value is Blob}
*/
const isBlob = (value) => value instanceof Blob
/**
* @template {TokenInput} T
* @param {EncodedBlobBlob<T>} input
*/
const mapTokenInputBlobs = (input) => {
return mapValueWith(input, isBlob, encodeBlob, new Map(), [])
}
/**
* Substitues values in the given `input` that match `p(value) == true` with
* `f(value, context, path)` where `context` is whatever you pass (usually
* a mutable state) and `path` is a array of keys / indexes where the value
* was encountered.
*
* @template T, I, X, O, State
* @param {import('./lib/interface.js').Encoded<T, [[I, X]]>} input - Arbitrary input.
* @param {(input:any, state:State, path:PropertyKey[]) => input is X} p - Predicate function to determine
* which values to swap.
* @param {(state:State, input:X, path:PropertyKey[]) => [State, O]} f - Function
* that swaps matching values.
* @param {State} state - Some additional context you need in the process.
* likey you'll start with `[]`.
* @returns {import('./lib/interface.js').Encoded<T, [[I, O]]>}
*/
export const mapWith = (input, p, f, state) => {
const [, output] = mapValueWith(input, p, f, state, [])
return output
}
/**
* @template T, I, X, O, State
* @param {import('./lib/interface.js').Encoded<T, [[I, X]]>} input - Arbitrary input.
* @param {(input:any, state:State, path:PropertyKey[]) => input is X} p - Predicate function to determine
* which values to swap.
* @param {(state:State, input:X, path:PropertyKey[]) => [State, O]} f - Function
* that swaps matching values.
* @param {State} state - Some additional context you need in the process.
* @param {PropertyKey[]} path - Path where the value was encountered. Most
* likey you'll start with `[]`.
* @returns {[State, import('./lib/interface.js').Encoded<T, [[I, O]]>]}
*/
const mapValueWith = (input, p, f, state, path) =>
p(input, state, path)
? f(state, input, path)
: Array.isArray(input)
? mapArrayWith(input, p, f, state, path)
: isObject(input)
? mapObjectWith(input, p, f, state, path)
: [state, /** @type {any} */ (input)]
/**
* Just like `mapWith` except
*
* @template State, T, I, X, O
* @param {import('./lib/interface.js').Encoded<T, [[I, X]]>} input
* @param {(input:any, state:State, path:PropertyKey[]) => input is X} p
* @param {(state: State, input:X, path:PropertyKey[]) => [State, O]} f
* @param {State} init
* @param {PropertyKey[]} path
* @returns {[State, import('./lib/interface.js').Encoded<T, [[I, O]]>]}
*/
const mapObjectWith = (input, p, f, init, path) => {
let state = init
const output =
/** @type {import('./lib/interface.js').Encoded<T, [[I, O]]>} */ ({})
for (const [key, value] of Object.entries(input)) {
const [next, out] = mapValueWith(value, p, f, state, [...path, key])
// @ts-ignore
output[key] = out
state = next
}
return [state, output]
}
/**
* Just like `mapWith` except for Arrays.
*
* @template I, X, O, State
* @template {any[]} T
* @param {T} input
* @param {(input:any, state:State, path:PropertyKey[]) => input is X} p
* @param {(state: State, input:X, path:PropertyKey[]) => [State, O]} f
* @param {State} init
* @param {PropertyKey[]} path
* @returns {[State, import('./lib/interface.js').Encoded<T, [[I, O]]>]}
*/
const mapArrayWith = (input, p, f, init, path) => {
const output = /** @type {unknown[]} */ ([])
let state = init
for (const [index, element] of input.entries()) {
const [next, out] = mapValueWith(element, p, f, state, [...path, index])
output[index] = out
state = next
}
return [
state,
/** @type {import('./lib/interface.js').Encoded<T, [[I, O]]>} */ (output),
]
}
/**
* Sets a given `value` at the given `path` on a passed `object`.
*
* @example
* ```js
* const obj = { a: { b: { c: 1 }}}
* setIn(obj, ['a', 'b', 'c'], 5)
* obj.a.b.c //> 5
* ```
*
* @template V
* @param {any} object
* @param {string[]} path
* @param {V} value
*/
const setIn = (object, path, value) => {
const n = path.length - 1
let target = object
for (let [index, key] of path.entries()) {
if (index === n) {
target[key] = value
} else {
target = target[key]
}
}
}