discover.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. const axios = require('axios')
  2. const debug = require('debug')('joystream:discovery:discover')
  3. const stripEndingSlash = require('@joystream/storage-utils/stripEndingSlash')
  4. const ipfs = require('ipfs-http-client')('localhost', '5001', { protocol: 'http' })
  5. const BN = require('bn.js')
  6. const { newExternallyControlledPromise } = require('@joystream/storage-utils/externalPromise')
  7. /**
  8. * Determines if code is running in a browser by testing for the global window object.
  9. * @return {boolean} returns result check.
  10. */
  11. function inBrowser() {
  12. return typeof window !== 'undefined'
  13. }
  14. /**
  15. * Map storage-provider id to a Promise of a discovery result. The purpose
  16. * is to avoid concurrent active discoveries for the same provider.
  17. */
  18. const activeDiscoveries = {}
  19. /**
  20. * Map of storage provider id to string
  21. * Cache of past discovery lookup results
  22. */
  23. const accountInfoCache = {}
  24. /**
  25. * After what period of time a cached record is considered stale, and would
  26. * trigger a re-discovery, but only if a query is made for the same provider.
  27. */
  28. const CACHE_TTL = 60 * 60 * 1000
  29. /**
  30. * Queries the ipns id (service key) of the storage provider from the blockchain.
  31. * If the storage provider is not registered it will return null.
  32. * @param {number | BN | u64} storageProviderId - the provider id to lookup
  33. * @param { RuntimeApi } runtimeApi - api instance to query the chain
  34. * @returns { Promise<string | null> } - ipns multiformat address
  35. */
  36. async function getIpnsIdentity(storageProviderId, runtimeApi) {
  37. storageProviderId = new BN(storageProviderId)
  38. // lookup ipns identity from chain corresponding to storageProviderId
  39. const info = await runtimeApi.discovery.getAccountInfo(storageProviderId)
  40. if (info === null) {
  41. // no identity found on chain for account
  42. return null
  43. }
  44. return info.identity.toString()
  45. }
  46. /**
  47. * Resolves provider id to its service information.
  48. * Will use an IPFS HTTP gateway. If caller doesn't provide a url the default gateway on
  49. * the local ipfs node will be used.
  50. * If the storage provider is not registered it will throw an error
  51. * @param {number | BN | u64} storageProviderId - the provider id to lookup
  52. * @param {RuntimeApi} runtimeApi - api instance to query the chain
  53. * @param {string} gateway - optional ipfs http gateway url to perform ipfs queries
  54. * @returns { Promise<object> } - the published service information
  55. */
  56. async function discoverOverIpfsHttpGateway(storageProviderId, runtimeApi, gateway = 'http://localhost:8080') {
  57. storageProviderId = new BN(storageProviderId)
  58. const isProvider = await runtimeApi.workers.isStorageProvider(storageProviderId)
  59. if (!isProvider) {
  60. throw new Error('Cannot discover non storage providers')
  61. }
  62. const identity = await getIpnsIdentity(storageProviderId, runtimeApi)
  63. if (identity === null) {
  64. // dont waste time trying to resolve if no identity was found
  65. throw new Error('no identity to resolve')
  66. }
  67. gateway = stripEndingSlash(gateway)
  68. const url = `${gateway}/ipns/${identity}`
  69. const response = await axios.get(url)
  70. return response.data
  71. }
  72. /**
  73. * Resolves id of provider to its service information.
  74. * Will use the provided colossus discovery api endpoint. If no api endpoint
  75. * is provided it attempts to use the configured endpoints from the chain.
  76. * If the storage provider is not registered it will throw an error
  77. * @param {number | BN | u64 } storageProviderId - provider id to lookup
  78. * @param {RuntimeApi} runtimeApi - api instance to query the chain
  79. * @param {string} discoverApiEndpoint - url for a colossus discovery api endpoint
  80. * @returns { Promise<object> } - the published service information
  81. */
  82. async function discoverOverJoystreamDiscoveryService(storageProviderId, runtimeApi, discoverApiEndpoint) {
  83. storageProviderId = new BN(storageProviderId)
  84. const isProvider = await runtimeApi.workers.isStorageProvider(storageProviderId)
  85. if (!isProvider) {
  86. throw new Error('Cannot discover non storage providers')
  87. }
  88. const identity = await getIpnsIdentity(storageProviderId, runtimeApi)
  89. // dont waste time trying to resolve if no identity was found
  90. if (identity === null) {
  91. throw new Error('no identity to resolve')
  92. }
  93. if (!discoverApiEndpoint) {
  94. // Use bootstrap nodes
  95. const discoveryBootstrapNodes = await runtimeApi.discovery.getBootstrapEndpoints()
  96. if (discoveryBootstrapNodes.length) {
  97. discoverApiEndpoint = stripEndingSlash(discoveryBootstrapNodes[0].toString())
  98. } else {
  99. throw new Error('No known discovery bootstrap nodes found on network')
  100. }
  101. }
  102. const url = `${discoverApiEndpoint}/discover/v0/${storageProviderId.toNumber()}`
  103. // should have parsed if data was json?
  104. const response = await axios.get(url)
  105. return response.data
  106. }
  107. /**
  108. * Resolves id of provider to its service information.
  109. * Will use the local IPFS node over RPC interface.
  110. * If the storage provider is not registered it will throw an error.
  111. * @param {number | BN | u64 } storageProviderId - provider id to lookup
  112. * @param {RuntimeApi} runtimeApi - api instance to query the chain
  113. * @returns { Promise<object> } - the published service information
  114. */
  115. async function discoverOverLocalIpfsNode(storageProviderId, runtimeApi) {
  116. storageProviderId = new BN(storageProviderId)
  117. const isProvider = await runtimeApi.workers.isStorageProvider(storageProviderId)
  118. if (!isProvider) {
  119. throw new Error('Cannot discover non storage providers')
  120. }
  121. const identity = await getIpnsIdentity(storageProviderId, runtimeApi)
  122. if (identity === null) {
  123. // dont waste time trying to resolve if no identity was found
  124. throw new Error('no identity to resolve')
  125. }
  126. const ipnsAddress = `/ipns/${identity}/`
  127. debug('resolved ipns to ipfs object')
  128. // Can this call hang forever!? can/should we set a timeout?
  129. const ipfsName = await ipfs.name.resolve(ipnsAddress, {
  130. // don't recurse, there should only be one indirection to the service info file
  131. recursive: false,
  132. nocache: false,
  133. })
  134. debug('getting ipfs object', ipfsName)
  135. const data = await ipfs.get(ipfsName) // this can sometimes hang forever!?! can we set a timeout?
  136. // there should only be one file published under the resolved path
  137. const content = data[0].content
  138. return JSON.parse(content)
  139. }
  140. /**
  141. * Internal method that handles concurrent discoveries and caching of results. Will
  142. * select the appropriate discovery protocol based on whether we are in a browser environment or not.
  143. * If not in a browser it expects a local ipfs node to be running.
  144. * @param {number | BN | u64} storageProviderId - ID of the storage provider
  145. * @param {RuntimeApi} runtimeApi - api instance for querying the chain
  146. * @returns { Promise<object | null> } - the published service information
  147. */
  148. async function _discover(storageProviderId, runtimeApi) {
  149. storageProviderId = new BN(storageProviderId)
  150. const id = storageProviderId.toNumber()
  151. const discoveryResult = activeDiscoveries[id]
  152. if (discoveryResult) {
  153. debug('discovery in progress waiting for result for', id)
  154. return discoveryResult
  155. }
  156. debug('starting new discovery for', id)
  157. const deferredDiscovery = newExternallyControlledPromise()
  158. activeDiscoveries[id] = deferredDiscovery.promise
  159. let result
  160. try {
  161. if (inBrowser()) {
  162. result = await discoverOverJoystreamDiscoveryService(storageProviderId, runtimeApi)
  163. } else {
  164. result = await discoverOverLocalIpfsNode(storageProviderId, runtimeApi)
  165. }
  166. debug(result)
  167. result = JSON.stringify(result)
  168. accountInfoCache[id] = {
  169. value: result,
  170. updated: Date.now(),
  171. }
  172. deferredDiscovery.resolve(result)
  173. delete activeDiscoveries[id]
  174. return result
  175. } catch (err) {
  176. // we catch the error so we can update all callers
  177. // and throw again to inform the first caller.
  178. debug(err.message)
  179. delete activeDiscoveries[id]
  180. // deferredDiscovery.reject(err)
  181. deferredDiscovery.resolve(null) // resolve to null until we figure out the issue below
  182. // throw err // <-- throwing but this isn't being
  183. // caught correctly in express server! Is it because there is an uncaught promise somewhere
  184. // in the prior .reject() call ?
  185. // I've only seen this behaviour when error is from ipfs-client
  186. // ... is this unique to errors thrown from ipfs-client?
  187. // Problem is its crashing the node so just return null for now
  188. return null
  189. }
  190. }
  191. /**
  192. * Cached discovery of storage provider service information. If useCachedValue is
  193. * set to true, will always return the cached result if found. New discovery will be triggered
  194. * if record is found to be stale. If a stale record is not desired (CACHE_TTL old) pass a non zero
  195. * value for maxCacheAge, which will force a new discovery and return the new resolved value.
  196. * This method in turn calls _discovery which handles concurrent discoveries and selects the appropriate
  197. * protocol to perform the query.
  198. * If the storage provider is not registered it will resolve to null
  199. * @param {number | BN | u64} storageProviderId - provider to discover
  200. * @param {RuntimeApi} runtimeApi - api instance to query the chain
  201. * @param {bool} useCachedValue - optionaly use chached queries
  202. * @param {number} maxCacheAge - maximum age of a cached query that triggers automatic re-discovery
  203. * @returns { Promise<object | null> } - the published service information
  204. */
  205. async function discover(storageProviderId, runtimeApi, useCachedValue = false, maxCacheAge = 0) {
  206. storageProviderId = new BN(storageProviderId)
  207. const id = storageProviderId.toNumber()
  208. const cached = accountInfoCache[id]
  209. if (cached && useCachedValue) {
  210. if (maxCacheAge > 0) {
  211. // get latest value
  212. if (Date.now() > cached.updated + maxCacheAge) {
  213. return _discover(storageProviderId, runtimeApi)
  214. }
  215. }
  216. // refresh if cache if stale, new value returned on next cached query
  217. if (Date.now() > cached.updated + CACHE_TTL) {
  218. _discover(storageProviderId, runtimeApi)
  219. }
  220. // return best known value
  221. return cached.value
  222. }
  223. return _discover(storageProviderId, runtimeApi)
  224. }
  225. module.exports = {
  226. discover,
  227. discoverOverJoystreamDiscoveryService,
  228. discoverOverIpfsHttpGateway,
  229. discoverOverLocalIpfsNode,
  230. }