index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. "use strict";
  2. import { decode as base64Decode, encode as base64Encode } from "@ethersproject/base64";
  3. import { hexlify, isBytesLike } from "@ethersproject/bytes";
  4. import { shallowCopy } from "@ethersproject/properties";
  5. import { toUtf8Bytes, toUtf8String } from "@ethersproject/strings";
  6. import { Logger } from "@ethersproject/logger";
  7. import { version } from "./_version";
  8. const logger = new Logger(version);
  9. import { getUrl, GetUrlResponse, Options } from "./geturl";
  10. function staller(duration: number): Promise<void> {
  11. return new Promise((resolve) => {
  12. setTimeout(resolve, duration);
  13. });
  14. }
  15. function bodyify(value: any, type: string): string {
  16. if (value == null) { return null; }
  17. if (typeof(value) === "string") { return value; }
  18. if (isBytesLike(value)) {
  19. if (type && (type.split("/")[0] === "text" || type.split(";")[0].trim() === "application/json")) {
  20. try {
  21. return toUtf8String(value);
  22. } catch (error) { };
  23. }
  24. return hexlify(value);
  25. }
  26. return value;
  27. }
  28. // Exported Types
  29. export type ConnectionInfo = {
  30. url: string,
  31. headers?: { [key: string]: string | number }
  32. user?: string,
  33. password?: string,
  34. allowInsecureAuthentication?: boolean,
  35. allowGzip?: boolean,
  36. throttleLimit?: number,
  37. throttleSlotInterval?: number;
  38. throttleCallback?: (attempt: number, url: string) => Promise<boolean>,
  39. skipFetchSetup?: boolean;
  40. errorPassThrough?: boolean;
  41. timeout?: number,
  42. };
  43. export interface OnceBlockable {
  44. once(eventName: "block", handler: () => void): void;
  45. }
  46. export interface OncePollable {
  47. once(eventName: "poll", handler: () => void): void;
  48. }
  49. export type PollOptions = {
  50. timeout?: number,
  51. floor?: number,
  52. ceiling?: number,
  53. interval?: number,
  54. retryLimit?: number,
  55. onceBlock?: OnceBlockable
  56. oncePoll?: OncePollable
  57. };
  58. export type FetchJsonResponse = {
  59. statusCode: number;
  60. headers: { [ header: string ]: string };
  61. };
  62. type Header = { key: string, value: string };
  63. // This API is still a work in progress; the future changes will likely be:
  64. // - ConnectionInfo => FetchDataRequest<T = any>
  65. // - FetchDataRequest.body? = string | Uint8Array | { contentType: string, data: string | Uint8Array }
  66. // - If string => text/plain, Uint8Array => application/octet-stream (if content-type unspecified)
  67. // - FetchDataRequest.processFunc = (body: Uint8Array, response: FetchDataResponse) => T
  68. // For this reason, it should be considered internal until the API is finalized
  69. export function _fetchData<T = Uint8Array>(connection: string | ConnectionInfo, body?: Uint8Array, processFunc?: (value: Uint8Array, response: FetchJsonResponse) => T): Promise<T> {
  70. // How many times to retry in the event of a throttle
  71. const attemptLimit = (typeof(connection) === "object" && connection.throttleLimit != null) ? connection.throttleLimit: 12;
  72. logger.assertArgument((attemptLimit > 0 && (attemptLimit % 1) === 0),
  73. "invalid connection throttle limit", "connection.throttleLimit", attemptLimit);
  74. const throttleCallback = ((typeof(connection) === "object") ? connection.throttleCallback: null);
  75. const throttleSlotInterval = ((typeof(connection) === "object" && typeof(connection.throttleSlotInterval) === "number") ? connection.throttleSlotInterval: 100);
  76. logger.assertArgument((throttleSlotInterval > 0 && (throttleSlotInterval % 1) === 0),
  77. "invalid connection throttle slot interval", "connection.throttleSlotInterval", throttleSlotInterval);
  78. const errorPassThrough = ((typeof(connection) === "object") ? !!(connection.errorPassThrough): false);
  79. const headers: { [key: string]: Header } = { };
  80. let url: string = null;
  81. // @TODO: Allow ConnectionInfo to override some of these values
  82. const options: Options = {
  83. method: "GET",
  84. };
  85. let allow304 = false;
  86. let timeout = 2 * 60 * 1000;
  87. if (typeof(connection) === "string") {
  88. url = connection;
  89. } else if (typeof(connection) === "object") {
  90. if (connection == null || connection.url == null) {
  91. logger.throwArgumentError("missing URL", "connection.url", connection);
  92. }
  93. url = connection.url;
  94. if (typeof(connection.timeout) === "number" && connection.timeout > 0) {
  95. timeout = connection.timeout;
  96. }
  97. if (connection.headers) {
  98. for (const key in connection.headers) {
  99. headers[key.toLowerCase()] = { key: key, value: String(connection.headers[key]) };
  100. if (["if-none-match", "if-modified-since"].indexOf(key.toLowerCase()) >= 0) {
  101. allow304 = true;
  102. }
  103. }
  104. }
  105. options.allowGzip = !!connection.allowGzip;
  106. if (connection.user != null && connection.password != null) {
  107. if (url.substring(0, 6) !== "https:" && connection.allowInsecureAuthentication !== true) {
  108. logger.throwError(
  109. "basic authentication requires a secure https url",
  110. Logger.errors.INVALID_ARGUMENT,
  111. { argument: "url", url: url, user: connection.user, password: "[REDACTED]" }
  112. );
  113. }
  114. const authorization = connection.user + ":" + connection.password;
  115. headers["authorization"] = {
  116. key: "Authorization",
  117. value: "Basic " + base64Encode(toUtf8Bytes(authorization))
  118. };
  119. }
  120. if (connection.skipFetchSetup != null) {
  121. options.skipFetchSetup = !!connection.skipFetchSetup;
  122. }
  123. }
  124. const reData = new RegExp("^data:([a-z0-9-]+/[a-z0-9-]+);base64,(.*)$", "i");
  125. const dataMatch = ((url) ? url.match(reData): null);
  126. if (dataMatch) {
  127. try {
  128. const response = {
  129. statusCode: 200,
  130. statusMessage: "OK",
  131. headers: { "content-type": dataMatch[1] },
  132. body: base64Decode(dataMatch[2])
  133. };
  134. let result: T = <T><unknown>response.body;
  135. if (processFunc) {
  136. result = processFunc(response.body, response);
  137. }
  138. return Promise.resolve(<T><unknown>result);
  139. } catch (error) {
  140. logger.throwError("processing response error", Logger.errors.SERVER_ERROR, {
  141. body: bodyify(dataMatch[1], dataMatch[2]),
  142. error: error,
  143. requestBody: null,
  144. requestMethod: "GET",
  145. url: url
  146. });
  147. }
  148. }
  149. if (body) {
  150. options.method = "POST";
  151. options.body = body;
  152. if (headers["content-type"] == null) {
  153. headers["content-type"] = { key: "Content-Type", value: "application/octet-stream" };
  154. }
  155. if (headers["content-length"] == null) {
  156. headers["content-length"] = { key: "Content-Length", value: String(body.length) };
  157. }
  158. }
  159. const flatHeaders: { [ key: string ]: string } = { };
  160. Object.keys(headers).forEach((key) => {
  161. const header = headers[key];
  162. flatHeaders[header.key] = header.value;
  163. });
  164. options.headers = flatHeaders;
  165. const runningTimeout = (function() {
  166. let timer: NodeJS.Timer = null;
  167. const promise: Promise<never> = new Promise(function(resolve, reject) {
  168. if (timeout) {
  169. timer = setTimeout(() => {
  170. if (timer == null) { return; }
  171. timer = null;
  172. reject(logger.makeError("timeout", Logger.errors.TIMEOUT, {
  173. requestBody: bodyify(options.body, flatHeaders["content-type"]),
  174. requestMethod: options.method,
  175. timeout: timeout,
  176. url: url
  177. }));
  178. }, timeout);
  179. }
  180. });
  181. const cancel = function() {
  182. if (timer == null) { return; }
  183. clearTimeout(timer);
  184. timer = null;
  185. }
  186. return { promise, cancel };
  187. })();
  188. const runningFetch = (async function() {
  189. for (let attempt = 0; attempt < attemptLimit; attempt++) {
  190. let response: GetUrlResponse = null;
  191. try {
  192. response = await getUrl(url, options);
  193. if (attempt < attemptLimit) {
  194. if (response.statusCode === 301 || response.statusCode === 302) {
  195. // Redirection; for now we only support absolute locataions
  196. const location = response.headers.location || "";
  197. if (options.method === "GET" && location.match(/^https:/)) {
  198. url = response.headers.location;
  199. continue;
  200. }
  201. } else if (response.statusCode === 429) {
  202. // Exponential back-off throttling
  203. let tryAgain = true;
  204. if (throttleCallback) {
  205. tryAgain = await throttleCallback(attempt, url);
  206. }
  207. if (tryAgain) {
  208. let stall = 0;
  209. const retryAfter = response.headers["retry-after"];
  210. if (typeof(retryAfter) === "string" && retryAfter.match(/^[1-9][0-9]*$/)) {
  211. stall = parseInt(retryAfter) * 1000;
  212. } else {
  213. stall = throttleSlotInterval * parseInt(String(Math.random() * Math.pow(2, attempt)));
  214. }
  215. //console.log("Stalling 429");
  216. await staller(stall);
  217. continue;
  218. }
  219. }
  220. }
  221. } catch (error) {
  222. response = (<any>error).response;
  223. if (response == null) {
  224. runningTimeout.cancel();
  225. logger.throwError("missing response", Logger.errors.SERVER_ERROR, {
  226. requestBody: bodyify(options.body, flatHeaders["content-type"]),
  227. requestMethod: options.method,
  228. serverError: error,
  229. url: url
  230. });
  231. }
  232. }
  233. let body = response.body;
  234. if (allow304 && response.statusCode === 304) {
  235. body = null;
  236. } else if (!errorPassThrough && (response.statusCode < 200 || response.statusCode >= 300)) {
  237. runningTimeout.cancel();
  238. logger.throwError("bad response", Logger.errors.SERVER_ERROR, {
  239. status: response.statusCode,
  240. headers: response.headers,
  241. body: bodyify(body, ((response.headers) ? response.headers["content-type"]: null)),
  242. requestBody: bodyify(options.body, flatHeaders["content-type"]),
  243. requestMethod: options.method,
  244. url: url
  245. });
  246. }
  247. if (processFunc) {
  248. try {
  249. const result = await processFunc(body, response);
  250. runningTimeout.cancel();
  251. return result;
  252. } catch (error) {
  253. // Allow the processFunc to trigger a throttle
  254. if (error.throttleRetry && attempt < attemptLimit) {
  255. let tryAgain = true;
  256. if (throttleCallback) {
  257. tryAgain = await throttleCallback(attempt, url);
  258. }
  259. if (tryAgain) {
  260. const timeout = throttleSlotInterval * parseInt(String(Math.random() * Math.pow(2, attempt)));
  261. //console.log("Stalling callback");
  262. await staller(timeout);
  263. continue;
  264. }
  265. }
  266. runningTimeout.cancel();
  267. logger.throwError("processing response error", Logger.errors.SERVER_ERROR, {
  268. body: bodyify(body, ((response.headers) ? response.headers["content-type"]: null)),
  269. error: error,
  270. requestBody: bodyify(options.body, flatHeaders["content-type"]),
  271. requestMethod: options.method,
  272. url: url
  273. });
  274. }
  275. }
  276. runningTimeout.cancel();
  277. // If we had a processFunc, it either returned a T or threw above.
  278. // The "body" is now a Uint8Array.
  279. return <T>(<unknown>body);
  280. }
  281. return logger.throwError("failed response", Logger.errors.SERVER_ERROR, {
  282. requestBody: bodyify(options.body, flatHeaders["content-type"]),
  283. requestMethod: options.method,
  284. url: url
  285. });
  286. })();
  287. return Promise.race([ runningTimeout.promise, runningFetch ]);
  288. }
  289. export function fetchJson(connection: string | ConnectionInfo, json?: string, processFunc?: (value: any, response: FetchJsonResponse) => any): Promise<any> {
  290. let processJsonFunc = (value: Uint8Array, response: FetchJsonResponse) => {
  291. let result: any = null;
  292. if (value != null) {
  293. try {
  294. result = JSON.parse(toUtf8String(value));
  295. } catch (error) {
  296. logger.throwError("invalid JSON", Logger.errors.SERVER_ERROR, {
  297. body: value,
  298. error: error
  299. });
  300. }
  301. }
  302. if (processFunc) {
  303. result = processFunc(result, response);
  304. }
  305. return result;
  306. }
  307. // If we have json to send, we must
  308. // - add content-type of application/json (unless already overridden)
  309. // - convert the json to bytes
  310. let body: Uint8Array = null;
  311. if (json != null) {
  312. body = toUtf8Bytes(json);
  313. // Create a connection with the content-type set for JSON
  314. const updated: ConnectionInfo = (typeof(connection) === "string") ? ({ url: connection }): shallowCopy(connection);
  315. if (updated.headers) {
  316. const hasContentType = (Object.keys(updated.headers).filter((k) => (k.toLowerCase() === "content-type")).length) !== 0;
  317. if (!hasContentType) {
  318. updated.headers = shallowCopy(updated.headers);
  319. updated.headers["content-type"] = "application/json";
  320. }
  321. } else {
  322. updated.headers = { "content-type": "application/json" };
  323. }
  324. connection = updated;
  325. }
  326. return _fetchData<any>(connection, body, processJsonFunc);
  327. }
  328. export function poll<T>(func: () => Promise<T>, options?: PollOptions): Promise<T> {
  329. if (!options) { options = {}; }
  330. options = shallowCopy(options);
  331. if (options.floor == null) { options.floor = 0; }
  332. if (options.ceiling == null) { options.ceiling = 10000; }
  333. if (options.interval == null) { options.interval = 250; }
  334. return new Promise(function(resolve, reject) {
  335. let timer: NodeJS.Timer = null;
  336. let done: boolean = false;
  337. // Returns true if cancel was successful. Unsuccessful cancel means we're already done.
  338. const cancel = (): boolean => {
  339. if (done) { return false; }
  340. done = true;
  341. if (timer) { clearTimeout(timer); }
  342. return true;
  343. };
  344. if (options.timeout) {
  345. timer = setTimeout(() => {
  346. if (cancel()) { reject(new Error("timeout")); }
  347. }, options.timeout)
  348. }
  349. const retryLimit = options.retryLimit;
  350. let attempt = 0;
  351. function check() {
  352. return func().then(function(result) {
  353. // If we have a result, or are allowed null then we're done
  354. if (result !== undefined) {
  355. if (cancel()) { resolve(result); }
  356. } else if (options.oncePoll) {
  357. options.oncePoll.once("poll", check);
  358. } else if (options.onceBlock) {
  359. options.onceBlock.once("block", check);
  360. // Otherwise, exponential back-off (up to 10s) our next request
  361. } else if (!done) {
  362. attempt++;
  363. if (attempt > retryLimit) {
  364. if (cancel()) { reject(new Error("retry limit reached")); }
  365. return;
  366. }
  367. let timeout = options.interval * parseInt(String(Math.random() * Math.pow(2, attempt)));
  368. if (timeout < options.floor) { timeout = options.floor; }
  369. if (timeout > options.ceiling) { timeout = options.ceiling; }
  370. setTimeout(check, timeout);
  371. }
  372. return null;
  373. }, function(error) {
  374. if (cancel()) { reject(error); }
  375. });
  376. }
  377. check();
  378. });
  379. }