airdcpp-apisocket
Version:
Javascript connector for AirDC++ Web API
236 lines (189 loc) • 6.68 kB
text/typescript
import chalk from 'chalk';
import invariant from 'invariant';
import Promise, { PendingResult } from './Promise.js';
import { eventIgnored } from './utils.js';
import * as API from './types/api.js';
import * as APIInternal from './types/api_internal.js';
import * as Options from './types/options.js';
import * as Socket from './types/socket.js';
import { Logger } from './types/logger.js';
import { SocketRequestMethods, ErrorResponse } from './types/requests.js';
interface Callback {
time: number;
resolver: PendingResult;
ignored: boolean;
}
const SocketRequestHandler = (
socket: () => Socket.APISocket,
logger: Logger,
{ requestTimeout = 30, ignoredRequestPaths }: Options.SocketRequestOptions
) => {
let callbacks: Record<string, Callback> = {};
let currentCallbackId = 0;
let timeoutReportInterval: any;
// Internal
// This creates a new callback ID for a request
const getCallbackId = () => {
if (currentCallbackId > 100000) {
currentCallbackId = 0;
}
currentCallbackId += 1;
return currentCallbackId;
};
const filterPassword = (data: object | undefined): object | undefined => {
if (!data || !data.hasOwnProperty('password')) {
return data;
}
return {
...data,
password: '(hidden)',
};
};
const sendRequest = <DataT extends object | undefined>(
method: string, path: string, data?: DataT, authenticating: boolean = false
) => {
// Pre-checks
if (!authenticating && !socket().isConnected()) {
logger.warn(`Attempting to send request on a non-authenticated socket: ${path}`);
return Promise.reject(new Error('Not authorized'));
}
if (!socket().nativeSocket) {
logger.warn(`Attempting to send request without a socket: ${path}`);
return Promise.reject(new Error('No socket'));
}
const callbackId = getCallbackId();
// Reporting
invariant(path, 'Attempting socket request without a path');
const ignored = eventIgnored(path, ignoredRequestPaths);
if (!ignored) {
logger.verbose(
chalk.white.bold(callbackId.toString()),
method,
path,
data ? filterPassword(data) : '(no data)'
);
}
// Callback
const resolver = Promise.pending();
callbacks[callbackId.toString()] = {
time: Date.now(),
resolver,
ignored,
};
// Actual request
const request = {
path,
method,
data,
callback_id: callbackId,
} as APIInternal.OutgoingRequest;
socket().nativeSocket!.send(JSON.stringify(request));
return resolver.promise;
};
// Report timed out requests
// This is more about spotting backend issues, such as frozen threads and dropped responses
// The socket itself should handle actual connection issues
const reportTimeouts = () => {
const now = Date.now();
Object.keys(callbacks).forEach(callbackId => {
const request = callbacks[callbackId];
if (request.time + (requestTimeout * 1000) < now) {
logger.warn(`Request ${callbackId} timed out`);
}
});
};
const cancelPendingRequests = (message: string = 'Request cancelled') => {
Object.keys(callbacks)
.forEach(id => {
logger.verbose(`Canceling a pending request ${id} (${message})`);
const cb: Callback = callbacks[id];
cb.resolver.reject(message);
});
callbacks = {};
};
// Public
const RequestsPublic: SocketRequestMethods = {
put: (path, data) => {
return sendRequest('PUT', path, data, );
},
patch: (path, data) => {
return sendRequest('PATCH', path, data, );
},
post: (path, data) => {
return sendRequest('POST', path, data);
},
delete: (path) => {
return sendRequest('DELETE', path);
},
get: (path) => {
return sendRequest('GET', path);
},
getPendingRequestCount: () => {
return Object.keys(callbacks).length;
},
};
Object.assign(RequestsPublic, {
reportRequestTimeouts: reportTimeouts, // internal method for testing
});
const formatFieldError = (error: API.FieldError) => {
return error.field && error.code ? `${error.field} (${error.code})` : '';
};
// Shared for the socket
const RequestsInternal = {
onSocketConnected() {
timeoutReportInterval = setInterval(reportTimeouts, 30000);
},
onSocketDisconnected() {
// Clear callbacks
cancelPendingRequests('Socket disconnected');
clearTimeout(timeoutReportInterval);
},
handleMessage(messageObj: APIInternal.RequestSuccessResponse | APIInternal.RequestErrorResponse) {
const id = messageObj.callback_id;
if (!callbacks.hasOwnProperty(id)) {
logger.warn('No pending request for an API response', id, messageObj);
return;
}
if (messageObj.code >= 200 && messageObj.code <= 204) {
const { data } = messageObj as APIInternal.RequestSuccessResponse;
if (!callbacks[id].ignored) {
logger.verbose(chalk.green(id.toString()), 'SUCCEEDED', data ?? '(no data)');
}
callbacks[id].resolver.resolve(data);
} else {
const errorMessageObj = messageObj as APIInternal.RequestErrorResponse;
if (!errorMessageObj.error) {
// API should always return an error message but this isn't always the case
// (e.g. https://github.com/airdcpp/airdcpp-windows/commit/596b31a9c8c4e72f6c9279972a40ea30f10798c4)
logger.warn(
'Error message missing from the response (this is an API bug that should be reported)',
id,
messageObj
);
}
const { code } = errorMessageObj;
const error = errorMessageObj.error || {
message: '(no error description)'
};
logger.warn(id, code, error.message, formatFieldError(error as API.FieldError));
callbacks[id].resolver.reject({
message: error.message,
code,
json: error
} as ErrorResponse);
}
delete callbacks[id];
},
postAuthenticate(
path: string,
data: API.TokenAuthenticationData | API.CredentialsAuthenticationData | API.RefreshTokenAuthenticationData
) {
return sendRequest('POST', path, data, true);
},
};
return {
...RequestsInternal,
socket: RequestsPublic,
};
};
export default SocketRequestHandler;