9b44b7eb91
## Issue 1263 Previously, we tried to inform the user that when an SDK call such as `support_create` and `publish` fails (specifically, timed out), the operation could be successful -- please check the transactions later. However, we only covered the case of `fetch` actually getting a response that indicated a timeout, e.g. "status = 524". For our SDK case, the timeout scenario is an error that goes into the `catch` block. In the `catch` block, we can't differentiate whether it is a timeout because it only returns a generic "failed to fetch" message. ## New Approach Since `fetch` does not support a timeout value, the usual solution is to wrap it with a `setTimeout`. This already exists in our code as `fetchWithTimeout` (yay). By setting a timeout that is lower than the browser's default and also lower than the SDK operation (90s for most commands, 5m for `publish`), we would now have a way to detect a timeout and inform the user. Firefox's 90s seems to be the lowest common denominator ... so 60s was chosen as the default (added some buffer). For the case of 'publish', it is actually called in the backend, so wrap the xhr call with a timeout as well.
377 lines
14 KiB
JavaScript
377 lines
14 KiB
JavaScript
// @flow
|
|
import { FETCH_TIMEOUT } from 'constants/errors';
|
|
import { NO_AUTH, X_LBRY_AUTH_TOKEN } from 'constants/token';
|
|
import fetchWithTimeout from 'util/fetch';
|
|
|
|
require('proxy-polyfill');
|
|
|
|
const CHECK_DAEMON_STARTED_TRY_NUMBER = 200;
|
|
|
|
// Basic LBRY sdk connection config
|
|
// Offers a proxy to call LBRY sdk methods
|
|
const Lbry = {
|
|
isConnected: false,
|
|
connectPromise: null,
|
|
daemonConnectionString: 'http://localhost:5279',
|
|
alternateConnectionString: '',
|
|
methodsUsingAlternateConnectionString: [],
|
|
apiRequestHeaders: { 'Content-Type': 'application/json-rpc' },
|
|
|
|
// Allow overriding daemon connection string (e.g. to `/api/proxy` for lbryweb)
|
|
setDaemonConnectionString: (value: string) => {
|
|
Lbry.daemonConnectionString = value;
|
|
},
|
|
|
|
setApiHeader: (key: string, value: string) => {
|
|
Lbry.apiRequestHeaders = Object.assign(Lbry.apiRequestHeaders, { [key]: value });
|
|
},
|
|
|
|
unsetApiHeader: (key) => {
|
|
Object.keys(Lbry.apiRequestHeaders).includes(key) && delete Lbry.apiRequestHeaders[key];
|
|
},
|
|
// Allow overriding Lbry methods
|
|
overrides: {},
|
|
setOverride: (methodName, newMethod) => {
|
|
Lbry.overrides[methodName] = newMethod;
|
|
},
|
|
getApiRequestHeaders: () => Lbry.apiRequestHeaders,
|
|
|
|
// Returns a human readable media type based on the content type or extension of a file that is returned by the sdk
|
|
getMediaType: (contentType: ?string, fileName: ?string) => {
|
|
if (fileName) {
|
|
const formats = [
|
|
[/\.(mp4|m4v|webm|flv|f4v|ogv)$/i, 'video'],
|
|
[/\.(mp3|m4a|aac|wav|flac|ogg|opus)$/i, 'audio'],
|
|
[/\.(jpeg|jpg|png|gif|svg|webp)$/i, 'image'],
|
|
[/\.(h|go|ja|java|js|jsx|c|cpp|cs|css|rb|scss|sh|php|py)$/i, 'script'],
|
|
[/\.(html|json|csv|txt|log|md|markdown|docx|pdf|xml|yml|yaml)$/i, 'document'],
|
|
[/\.(pdf|odf|doc|docx|epub|org|rtf)$/i, 'e-book'],
|
|
[/\.(stl|obj|fbx|gcode)$/i, '3D-file'],
|
|
[/\.(cbr|cbt|cbz)$/i, 'comic-book'],
|
|
[/\.(lbry)$/i, 'application'],
|
|
];
|
|
|
|
const res = formats.reduce((ret, testpair) => {
|
|
switch (testpair[0].test(ret)) {
|
|
case true:
|
|
return testpair[1];
|
|
default:
|
|
return ret;
|
|
}
|
|
}, fileName);
|
|
return res === fileName ? 'unknown' : res;
|
|
} else if (contentType) {
|
|
// $FlowFixMe
|
|
return /^[^/]+/.exec(contentType)[0];
|
|
}
|
|
|
|
return 'unknown';
|
|
},
|
|
|
|
//
|
|
// Lbry SDK Methods
|
|
// https://lbry.tech/api/sdk
|
|
//
|
|
status: (params = {}) => daemonCallWithResult('status', params),
|
|
stop: () => daemonCallWithResult('stop', {}),
|
|
version: () => daemonCallWithResult('version', {}),
|
|
|
|
// Claim fetching and manipulation
|
|
resolve: (params) => daemonCallWithResult('resolve', params, searchRequiresAuth),
|
|
get: (params) => daemonCallWithResult('get', params),
|
|
claim_search: (params) => daemonCallWithResult('claim_search', params, searchRequiresAuth),
|
|
claim_list: (params) => daemonCallWithResult('claim_list', params),
|
|
channel_create: (params) => daemonCallWithResult('channel_create', params),
|
|
channel_update: (params) => daemonCallWithResult('channel_update', params),
|
|
channel_import: (params) => daemonCallWithResult('channel_import', params),
|
|
channel_list: (params) => daemonCallWithResult('channel_list', params),
|
|
stream_abandon: (params) => daemonCallWithResult('stream_abandon', params),
|
|
stream_list: (params) => daemonCallWithResult('stream_list', params),
|
|
channel_abandon: (params) => daemonCallWithResult('channel_abandon', params),
|
|
channel_sign: (params) => daemonCallWithResult('channel_sign', params),
|
|
support_create: (params) => daemonCallWithResult('support_create', params),
|
|
support_list: (params) => daemonCallWithResult('support_list', params),
|
|
stream_repost: (params) => daemonCallWithResult('stream_repost', params),
|
|
collection_resolve: (params) => daemonCallWithResult('collection_resolve', params),
|
|
collection_list: (params) => daemonCallWithResult('collection_list', params),
|
|
collection_create: (params) => daemonCallWithResult('collection_create', params),
|
|
collection_update: (params) => daemonCallWithResult('collection_update', params),
|
|
|
|
// File fetching and manipulation
|
|
file_list: (params = {}) => daemonCallWithResult('file_list', params),
|
|
file_delete: (params = {}) => daemonCallWithResult('file_delete', params),
|
|
file_set_status: (params = {}) => daemonCallWithResult('file_set_status', params),
|
|
blob_delete: (params = {}) => daemonCallWithResult('blob_delete', params),
|
|
blob_list: (params = {}) => daemonCallWithResult('blob_list', params),
|
|
file_reflect: (params = {}) => daemonCallWithResult('file_reflect', params),
|
|
|
|
// Wallet utilities
|
|
wallet_balance: (params = {}) => daemonCallWithResult('wallet_balance', params),
|
|
wallet_decrypt: () => daemonCallWithResult('wallet_decrypt', {}),
|
|
wallet_encrypt: (params = {}) => daemonCallWithResult('wallet_encrypt', params),
|
|
wallet_unlock: (params = {}) => daemonCallWithResult('wallet_unlock', params),
|
|
wallet_list: (params = {}) => daemonCallWithResult('wallet_list', params),
|
|
wallet_send: (params = {}) => daemonCallWithResult('wallet_send', params),
|
|
wallet_status: (params = {}) => daemonCallWithResult('wallet_status', params),
|
|
address_is_mine: (params = {}) => daemonCallWithResult('address_is_mine', params),
|
|
address_unused: (params = {}) => daemonCallWithResult('address_unused', params),
|
|
address_list: (params = {}) => daemonCallWithResult('address_list', params),
|
|
transaction_list: (params = {}) => daemonCallWithResult('transaction_list', params),
|
|
utxo_release: (params = {}) => daemonCallWithResult('utxo_release', params),
|
|
support_abandon: (params = {}) => daemonCallWithResult('support_abandon', params),
|
|
purchase_list: (params = {}) => daemonCallWithResult('purchase_list', params),
|
|
txo_list: (params = {}) => daemonCallWithResult('txo_list', params),
|
|
account_list: (params = {}) => daemonCallWithResult('account_list', params),
|
|
account_set: (params = {}) => daemonCallWithResult('account_set', params),
|
|
|
|
sync_hash: (params = {}) => daemonCallWithResult('sync_hash', params),
|
|
sync_apply: (params = {}) => daemonCallWithResult('sync_apply', params),
|
|
|
|
// Preferences
|
|
preference_get: (params = {}) => daemonCallWithResult('preference_get', params),
|
|
preference_set: (params = {}) => daemonCallWithResult('preference_set', params),
|
|
|
|
// Comments
|
|
comment_list: (params = {}) => daemonCallWithResult('comment_list', params),
|
|
comment_create: (params = {}) => daemonCallWithResult('comment_create', params),
|
|
comment_hide: (params = {}) => daemonCallWithResult('comment_hide', params),
|
|
comment_abandon: (params = {}) => daemonCallWithResult('comment_abandon', params),
|
|
comment_update: (params = {}) => daemonCallWithResult('comment_update', params),
|
|
|
|
// Connect to the sdk
|
|
connect: () => {
|
|
if (Lbry.connectPromise === null) {
|
|
// $FlowFixMe
|
|
Lbry.connectPromise = new Promise((resolve, reject) => {
|
|
let tryNum = 0;
|
|
// Check every half second to see if the daemon is accepting connections
|
|
function checkDaemonStarted() {
|
|
tryNum += 1;
|
|
Lbry.status()
|
|
.then(resolve)
|
|
.catch(() => {
|
|
if (tryNum <= CHECK_DAEMON_STARTED_TRY_NUMBER) {
|
|
setTimeout(checkDaemonStarted, tryNum < 50 ? 400 : 1000);
|
|
} else {
|
|
reject(new Error('Unable to connect to LBRY'));
|
|
}
|
|
});
|
|
}
|
|
|
|
checkDaemonStarted();
|
|
});
|
|
}
|
|
|
|
// Flow thinks this could be empty, but it will always reuturn a promise
|
|
// $FlowFixMe
|
|
return Lbry.connectPromise;
|
|
},
|
|
|
|
publish: (params = {}) =>
|
|
new Promise((resolve, reject) => {
|
|
if (Lbry.overrides.publish) {
|
|
Lbry.overrides.publish(params).then(resolve, reject);
|
|
} else {
|
|
apiCall('publish', params, resolve, reject);
|
|
}
|
|
}),
|
|
};
|
|
|
|
const ApiFailureMgr = {
|
|
MAX_FAILED_ATTEMPTS: 5,
|
|
MAX_FAILED_GAP_MS: 500,
|
|
BLOCKED_DURATION_MS: 60000,
|
|
METHODS_TO_LOG: ['claim_search'], // Can check all, but narrow do claim_search only for now.
|
|
|
|
failureTimestamps: {}, // { [key: string]: Array<timestamps: number> }
|
|
|
|
logFailure: function (method: string, params: ?{}, timestamp: number) {
|
|
if (this.isListedMethod(method)) {
|
|
const key = this.getKey(method, params);
|
|
const ts = this.failureTimestamps[key] || [];
|
|
ts.push(timestamp);
|
|
this.failureTimestamps[key] = ts;
|
|
}
|
|
},
|
|
|
|
logSuccess: function (method: string, params: ?{}) {
|
|
if (this.isListedMethod(method)) {
|
|
const key = this.getKey(method, params);
|
|
delete this.failureTimestamps[key];
|
|
}
|
|
},
|
|
|
|
isFailingAndShouldDrop: function (method: string, params: ?{}) {
|
|
if (this.isListedMethod(method)) {
|
|
const key = this.getKey(method, params);
|
|
const fts = this.failureTimestamps[key];
|
|
if (fts && fts.length > this.MAX_FAILED_ATTEMPTS) {
|
|
const ts2 = fts[fts.length - 1];
|
|
const ts1 = fts[fts.length - this.MAX_FAILED_ATTEMPTS];
|
|
const successivelyFailed = ts2 - ts1 < this.MAX_FAILED_GAP_MS;
|
|
return successivelyFailed && Date.now() - ts2 < this.BLOCKED_DURATION_MS;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
|
|
getKey: function (method: string, params: ?{}) {
|
|
return method + '/' + JSON.stringify(params || {});
|
|
},
|
|
|
|
isListedMethod: function (method: string) {
|
|
return this.METHODS_TO_LOG.includes(method);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Returns a customized error message for known scenarios.
|
|
*/
|
|
function resolveFetchErrorMsg(method: string, response: Response | string) {
|
|
if (typeof response === 'object') {
|
|
// prettier-ignore
|
|
switch (response.status) {
|
|
case 504: // Gateway timeout
|
|
case 524: // Cloudflare: a timeout occurred
|
|
switch (method) {
|
|
case 'publish':
|
|
return __('[Publish]: Your action timed out, but may have been completed. Refresh and check your Uploads or Wallet page to confirm after a few minutes.');
|
|
default:
|
|
return `${method}: ${response.statusText} (${response.status})`;
|
|
}
|
|
default:
|
|
return `${method}: ${response.statusText} (${response.status})`;
|
|
}
|
|
} else if (response === FETCH_TIMEOUT) {
|
|
return `${method}: Your action timed out, but may have been completed.`;
|
|
} else {
|
|
return `${method}: fetch failed.`;
|
|
}
|
|
}
|
|
|
|
function checkAndParse(response: Response, method: string) {
|
|
if (!response.ok) {
|
|
const errMsg = resolveFetchErrorMsg(method, response);
|
|
throw Error(errMsg);
|
|
}
|
|
|
|
if (response.status >= 200 && response.status < 300) {
|
|
return response.json();
|
|
}
|
|
|
|
return response
|
|
.json()
|
|
.then((json) => {
|
|
if (json.error) {
|
|
const errorMessage = typeof json.error === 'object' ? json.error.message : json.error;
|
|
return Promise.reject(new Error(errorMessage));
|
|
} else {
|
|
return Promise.reject(new Error('Protocol error with unknown response signature'));
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// If not parsable, throw the initial response rather than letting
|
|
// the json failure ("unexpected token at..") pass through.
|
|
return Promise.reject(new Error(`${method}: ${response.statusText} (${response.status}, JSON)`));
|
|
});
|
|
}
|
|
|
|
export function apiCall(method: string, params: ?{}, resolve: Function, reject: Function) {
|
|
let apiRequestHeaders = Lbry.apiRequestHeaders;
|
|
|
|
if (params && params[NO_AUTH]) {
|
|
apiRequestHeaders = Object.assign({}, Lbry.apiRequestHeaders);
|
|
delete apiRequestHeaders[X_LBRY_AUTH_TOKEN];
|
|
delete params[NO_AUTH];
|
|
}
|
|
|
|
const counter = new Date().getTime();
|
|
const options = {
|
|
method: 'POST',
|
|
headers: apiRequestHeaders,
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
method,
|
|
params,
|
|
id: counter,
|
|
}),
|
|
};
|
|
|
|
if (ApiFailureMgr.isFailingAndShouldDrop(method, params)) {
|
|
return Promise.reject('Dropped due to successive failures.');
|
|
}
|
|
|
|
const connectionString = Lbry.methodsUsingAlternateConnectionString.includes(method)
|
|
? Lbry.alternateConnectionString
|
|
: Lbry.daemonConnectionString;
|
|
|
|
const SDK_FETCH_TIMEOUT_MS = 60000;
|
|
return fetchWithTimeout(SDK_FETCH_TIMEOUT_MS, fetch(connectionString + '?m=' + method, options))
|
|
.then((response) => checkAndParse(response, method))
|
|
.then((response) => {
|
|
const error = response.error || (response.result && response.result.error);
|
|
if (error) {
|
|
ApiFailureMgr.logFailure(method, params, counter);
|
|
return reject(error);
|
|
} else {
|
|
ApiFailureMgr.logSuccess(method);
|
|
return resolve(response.result);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
ApiFailureMgr.logFailure(method, params, counter);
|
|
if (err?.message === FETCH_TIMEOUT) {
|
|
reject(resolveFetchErrorMsg(method, FETCH_TIMEOUT));
|
|
} else {
|
|
reject(err);
|
|
}
|
|
});
|
|
}
|
|
|
|
function daemonCallWithResult(
|
|
name: string,
|
|
params: ?{} = {},
|
|
checkAuthNeededFn: ?(?{}) => boolean = undefined
|
|
): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const skipAuth = checkAuthNeededFn ? !checkAuthNeededFn(params) : false;
|
|
|
|
apiCall(
|
|
name,
|
|
skipAuth ? { ...params, [NO_AUTH]: true } : params,
|
|
(result) => {
|
|
resolve(result);
|
|
},
|
|
reject
|
|
);
|
|
});
|
|
}
|
|
|
|
// This is only for a fallback
|
|
// If there is a Lbry method that is being called by an app, it should be added to /flow-typed/Lbry.js
|
|
const lbryProxy = new Proxy(Lbry, {
|
|
get(target: LbryTypes, name: string) {
|
|
if (name in target) {
|
|
return target[name];
|
|
}
|
|
|
|
return (params = {}) =>
|
|
new Promise((resolve, reject) => {
|
|
apiCall(name, params, resolve, reject);
|
|
});
|
|
},
|
|
});
|
|
|
|
/**
|
|
* daemonCallWithResult hook that checks if the search option requires the
|
|
* auth-token. This hook works for 'resolve' and 'claim_search'.
|
|
*
|
|
* @param options
|
|
* @returns {boolean}
|
|
*/
|
|
function searchRequiresAuth(options: any) {
|
|
const KEYS_REQUIRE_AUTH = ['include_purchase_receipt', 'include_is_my_output'];
|
|
return options && KEYS_REQUIRE_AUTH.some((k) => options.hasOwnProperty(k));
|
|
}
|
|
|
|
export default lbryProxy;
|