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 discover_over_ipfs_http_gateway(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 discover_over_joystream_discovery_service(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 discover_over_local_ipfs_node(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 ipns_address = `/ipns/${identity}/`
  127. debug('resolved ipns to ipfs object')
  128. // Can this call hang forever!? can/should we set a timeout?
  129. const ipfs_name = await ipfs.name.resolve(ipns_address, {
  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', ipfs_name)
  135. const data = await ipfs.get(ipfs_name) // 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. * Cached discovery of storage provider service information. If useCachedValue is
  142. * set to true, will always return the cached result if found. New discovery will be triggered
  143. * if record is found to be stale. If a stale record is not desired (CACHE_TTL old) pass a non zero
  144. * value for maxCacheAge, which will force a new discovery and return the new resolved value.
  145. * This method in turn calls _discovery which handles concurrent discoveries and selects the appropriate
  146. * protocol to perform the query.
  147. * If the storage provider is not registered it will resolve to null
  148. * @param {number | BN | u64} storageProviderId - provider to discover
  149. * @param {RuntimeApi} runtimeApi - api instance to query the chain
  150. * @param {bool} useCachedValue - optionaly use chached queries
  151. * @param {number} maxCacheAge - maximum age of a cached query that triggers automatic re-discovery
  152. * @returns { Promise<object | null> } - the published service information
  153. */
  154. async function discover(storageProviderId, runtimeApi, useCachedValue = false, maxCacheAge = 0) {
  155. storageProviderId = new BN(storageProviderId)
  156. const id = storageProviderId.toNumber()
  157. const cached = accountInfoCache[id]
  158. if (cached && useCachedValue) {
  159. if (maxCacheAge > 0) {
  160. // get latest value
  161. if (Date.now() > cached.updated + maxCacheAge) {
  162. return _discover(storageProviderId, runtimeApi)
  163. }
  164. }
  165. // refresh if cache if stale, new value returned on next cached query
  166. if (Date.now() > cached.updated + CACHE_TTL) {
  167. _discover(storageProviderId, runtimeApi)
  168. }
  169. // return best known value
  170. return cached.value
  171. }
  172. return _discover(storageProviderId, runtimeApi)
  173. }
  174. /**
  175. * Internal method that handles concurrent discoveries and caching of results. Will
  176. * select the appropriate discovery protocol based on whether we are in a browser environment or not.
  177. * If not in a browser it expects a local ipfs node to be running.
  178. * @param {number | BN | u64} storageProviderId - ID of the storage provider
  179. * @param {RuntimeApi} runtimeApi - api instance for querying the chain
  180. * @returns { Promise<object | null> } - the published service information
  181. */
  182. async function _discover(storageProviderId, runtimeApi) {
  183. storageProviderId = new BN(storageProviderId)
  184. const id = storageProviderId.toNumber()
  185. const discoveryResult = activeDiscoveries[id]
  186. if (discoveryResult) {
  187. debug('discovery in progress waiting for result for', id)
  188. return discoveryResult
  189. }
  190. debug('starting new discovery for', id)
  191. const deferredDiscovery = newExternallyControlledPromise()
  192. activeDiscoveries[id] = deferredDiscovery.promise
  193. let result
  194. try {
  195. if (inBrowser()) {
  196. result = await discover_over_joystream_discovery_service(storageProviderId, runtimeApi)
  197. } else {
  198. result = await discover_over_local_ipfs_node(storageProviderId, runtimeApi)
  199. }
  200. debug(result)
  201. result = JSON.stringify(result)
  202. accountInfoCache[id] = {
  203. value: result,
  204. updated: Date.now(),
  205. }
  206. deferredDiscovery.resolve(result)
  207. delete activeDiscoveries[id]
  208. return result
  209. } catch (err) {
  210. // we catch the error so we can update all callers
  211. // and throw again to inform the first caller.
  212. debug(err.message)
  213. delete activeDiscoveries[id]
  214. // deferredDiscovery.reject(err)
  215. deferredDiscovery.resolve(null) // resolve to null until we figure out the issue below
  216. // throw err // <-- throwing but this isn't being
  217. // caught correctly in express server! Is it because there is an uncaught promise somewhere
  218. // in the prior .reject() call ?
  219. // I've only seen this behaviour when error is from ipfs-client
  220. // ... is this unique to errors thrown from ipfs-client?
  221. // Problem is its crashing the node so just return null for now
  222. return null
  223. }
  224. }
  225. module.exports = {
  226. discover,
  227. discover_over_joystream_discovery_service,
  228. discover_over_ipfs_http_gateway,
  229. discover_over_local_ipfs_node,
  230. }