reveal-sdk-node
Version:
RevealBI Node.js SDK
892 lines (798 loc) • 26.8 kB
JavaScript
const http = require('http');
const { spawn, execSync } = require('child_process');
const url = require('url');
const { randomUUID } = require('crypto');
const os = require('os');
const path = require('path');
const fs = require('fs');
const puppeteer = require('puppeteer-core');
const puppeteerUtils = require("./exporter/PuppeteerUtils");
const { DashboardExporter } = require('./exporter/exporter');
const netpath = require('./netpath');
const dsHelper = require('./sdk-model/rvDataSourceHelper');
const dsExports = require('./sdk-model/rvDataSourceExports');
const create = function (options) {
var revealServer = createServer(options ?? {});
revealServer.start();
const reqListener = (req, res) => revealServer.process(req, res);
reqListener.close = (callback) => {
revealServer.stop(callback);
};
reqListener.exporter = new DashboardExporter(revealServer);
return reqListener;
};
const createServer = (options) => {
var srv = {
instanceUUID: randomUUID(),
'options': options,
state: 0, // 0 start pending, 1 starting, 2 started, 3 engine dead, 4 stopping
serverPromise: null,
executor: null,
userContexts : {}, // Initialized in the request, removed by the same request when done.
start: function() {
if (this.state != 0) throw new Error("Start has already been invoked");
console.log("RevealServer starting... [" + this.instanceUUID + "]");
this.state = 1;
this.launchCallbackServer();
},
stop: function(callback) {
this.state = 4;
if (this.callbacksServer) {
this.callbacksServer.close(callback);
this.callbacksServer = null;
}
if (this.engineCall) {
this.engineCall.kill();
this.engineCall = null;
}
},
launchCallbackServer: function() {
this.callbacksServer = http.createServer(async (req, res) => {
this.addReqErrorHandler(req, res, 'Callback request failed');
this.addResErrorHandler(res, 'Callback response failed');
var parsedUrl = url.parse(req.url);
var searchParams = new URLSearchParams(parsedUrl.search);
var userContextId = req.headers["revealbi-userid"];
var auth = req.headers["revealbi-auth"];
if (auth != this.callbackAuth) {
console.log("We've received an unauthenticated callback request");
res.statusCode = 404;
res.end();
return;
}
var userContext = this.userContexts[userContextId];
var handleParams = {
parsedUrl: parsedUrl,
searchParams: searchParams,
userContext: userContext
};
const path = parsedUrl.pathname.substr(1);
if (path == 'lifecycle-ready') {
await this.handleLifecycleReady(req, res, handleParams);
} else if (path == 'authentication-provider') {
await this.handleAuthenticationProvider(req, res, handleParams);
} else if (path == 'dashboard-provider') {
await this.handleDashboardProvider(req, res, handleParams);
} else if (path == 'dashboard-storage-provider') {
await this.handleDashboardStorageProvider(req, res, handleParams);
} else if (path == 'datasource-provider') {
await this.handleDataSourceProvider(req, res, handleParams);
} else if (path == 'datasource-item-provider') {
await this.handleDataSourceItemProvider(req, res, handleParams);
} else if (path == 'dsi-list-filter') {
await this.handleDataSourceItemListFilter(req, res, handleParams);
} else if (path == 'webpage-evaluate-function') {
await this.handleWebPageEvaluateFunction(req, res, handleParams);
} else {
await this.handleDefault(req, res, handleParams);
}
});
this.callbacksServer.listen(0, '127.0.0.1', () => {
if (this.state == 4) return; // shutting down.
this.launchDotNet(this.callbacksServer.address().port);
});
},
handleLifecycleReady: async function(_, res, handleParams) {
this.engineReady(handleParams.searchParams.get("port"));
res.statusCode = 200;
res.end();
},
handleAuthenticationProvider: async function(req, res, handleParams) {
await this.handleTransformation(req, res, handleParams, 'authenticationProvider', dsHelper.toSdkDS);
},
handleTransformation: async function(req, res, handleParams, transformerNameOrFunction, jsonDeserializer, resultToJsonFun = null) {
var transformer;
var transformerName;
if (typeof transformerNameOrFunction === 'function') {
transformer = transformerNameOrFunction;
transformerName = transformer.transformerName;
} else {
transformer = this.options[transformerNameOrFunction];
transformerName = transformerNameOrFunction;
}
var body = await streamToString(req);
var inputJson = JSON.parse(body);
var result;
try {
var sdkObject = jsonDeserializer(inputJson);
result = await transformer?.(handleParams.userContext, sdkObject);
} catch (e) {
console.error(`Failed to run ${transformerName}: ${e}\n${e.stack}`);
}
var resultJson = resultToJsonFun ? resultToJsonFun(result) : result?.toJson();
if (resultJson) {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/javascript');
var responseBody = JSON.stringify(resultJson);
res.end(responseBody);
} else {
res.statusCode = 200;
res.end();
}
},
handleDashboardProvider: async function(req, res, handleParams) {
var dId = handleParams.searchParams.get('dashboardId');
var dashboardProvider = this.options.dashboardProvider;
if (!dashboardProvider) {
res.statusCode = 404;
res.end();
} else {
var dashboardStream;
try {
dashboardStream = await dashboardProvider(handleParams.userContext, dId);
} catch (e) {
res.statusCode = 500;
res.end(`Dashboard load failed. Error name: ${e.name ?? "-"}. Message: ${e.message ?? ("" + e)}`);
return;
}
if (!dashboardStream) {
res.statusCode = 404;
res.end();
} else {
res.statusCode = 200;
dashboardStream.pipe(res, { end: true });
dashboardStream.on('error', (e) => {
console.log(`dashboard stream failed: ${e}\n${e.stack}`);
if (!res.headersSent) {
res.statusCode = 500;
}
res.end();
});
}
}
},
handleDashboardStorageProvider: async function(req, res, handleParams) {
var dId = handleParams.searchParams.get('dashboardId');
var dashboardStorageProvider = this.options.dashboardStorageProvider;
if (!dashboardStorageProvider) {
res.statusCode = 500;
res.end('Save not implemented');
} else {
try {
await dashboardStorageProvider(handleParams.userContext, dId, req);
} catch (e) {
res.statusCode = 500;
res.end(`Dashboard save failed. Error name: ${e.name ?? "-"}. Message: ${e.message ?? ("" + e)}`);
return;
}
res.statusCode = 200;
res.end();
}
},
handleDataSourceProvider: async function(req, res, handleParams) {
await this.handleTransformation(req, res, handleParams, 'dataSourceProvider', dsHelper.toSdkDS);
},
handleDataSourceItemProvider: async function(req, res, handleParams) {
await this.handleTransformation(req, res, handleParams, 'dataSourceItemProvider', dsHelper.toSdkDSI);
},
handleDataSourceItemListFilter: async function(req, res, handleParams) {
var dataSourceItemFilter = this.options.dataSourceItemFilter;
var transformerFunction = async function(userContext, dsiList) {
return await Promise.all(dsiList.map(async dsi => await dataSourceItemFilter(userContext, dsi)));
};
transformerFunction.transformerName = 'dataSourceItemFilter';
await this.handleTransformation(req, res, handleParams,
transformerFunction,
(jsonList) => jsonList.map(dsiJson => dsiJson != null ? dsHelper.toSdkDSI(dsiJson) : null),
(result) => result);
},
handleWebPageEvaluateFunction: async function(req, res, handleParams) {
var body = await streamToString(req);
var inputJson = JSON.parse(body);
const browser = await this.ensureScreenshotBrowser();
const page = await browser.newPage();
await page.setViewport({width: inputJson.Width, height: inputJson.Height});
page
.on('console', message => console.log(`${message.type().substr(0, 3).toUpperCase()} ${message.text()}`))
.on('pageerror', ({ message }) => console.error(message))
.on('response', response => console.log(`${response.status()} ${response.url()}`))
.on('requestfailed', request => console.log(`${request.failure().errorText} ${request.url()}`));
await page.goto(inputJson.PagePath);
if(inputJson.InitScript){
var scriptOptions = {content : inputJson.InitScript};
await page.addScriptTag(scriptOptions);
}
await page.exposeFunction("infragisticsScreenshot", async () => {
return await page.screenshot({ encoding: 'base64' });
});
var timeout = 60000;
if(inputJson.Timeout && inputJson.Timeout > 0){
timeout = inputJson.Timeout * 1000;
}
var isTimeout = false;
var timer = setTimeout( ()=>{
isTimeout = true;
page.close();
}, timeout);
try{
var result = await page.evaluate(Function(`return ${inputJson.F}`)());
timer.close();
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(result);
} catch(error){
res.statusCode = 500;
res.setHeader('Content-Type', 'text/plain');
if(isTimeout){
res.end("Timeout reached: " + timeout/1000 + "s");
}
else{
res.end(error);
}
} finally {
if(page != null && !page.isClosed()){
page.close();
}
}
},
handleDefault: async function(req, res, handleParams) {
console.log(`Unknown callback: ${handleParams.parsedUrl.url}`);
res.statusCode = 400;
res.end('Unknown callback');
},
launchDotNet: function(port) {
process.on('exit', () => {
if (this.state != 4) {
console.log('RevealEngine exited. [' + this.instanceUUID + ']');
}
if (this.engineCall) {
this.engineCall.kill();
this.engineCall = null;
}
});
this.callbackAuth = randomUUID();
var registry = [];
var dataSourcesModules = this.options.dataSources;
if (dataSourcesModules) {
dataSourcesModules.forEach(moduleName => {
const module = moduleName.startsWith('.') // this is something for internal testing.
? require(moduleName)
: require(` /data-${moduleName}`);
for (const expKey in module) {
const exp = module[expKey];
if (exp.hasOwnProperty('assemblyInfo')) {
dsHelper.registerDataSource(exp.identifier, exp.dataSourceClass, exp.dataSourceItemClass); // TODO this registers globally, not just for this server instance.
registry.push(exp.assemblyInfo);
}
}
});
}
var engineCallArgsObj = {
'cachePath': this.options.cachePath ?? null,
'callbacksPort': port,
'callbackAuth': this.callbackAuth,
'lic': this.options.license ?? null,
'logLevel': this.options.engineLogLevel ?? null,
'logDir': this.options.engineLogDir ?? path.resolve(`${os.tmpdir()}/revealbi-logs`),
'localFileStoragePath': this.options.localFileStoragePath ?? null,
'advancedSettings': this.options.advancedSettings ?? null,
'maxFilterSize': this.options.maxFilterSize ?? null,
'hasDataSourceItemFilter': this.options.dataSourceItemFilter != null,
'registry': registry,
'isLegacyCacheEnabled': this.options.isLegacyCacheEnabled ?? true,
'enableCacheEncryption': this.options.enableCacheEncryption ?? false,
'cacheEncryptionPassword': this.options.cacheEncryptionPassword ?? null,
'newLocalProcessingEnabled': this.options.newLocalProcessingEnabled ?? false
};
var engineCallArgs = JSON.stringify(engineCallArgsObj);
var nativeLibPath = this.options._internal_revealEnginePrgPath;
if (!nativeLibPath) {
nativeLibPath = __dirname + path.sep + ".." + path.sep + netpath.binaryRelativePath;
}
if (os.platform() != "win32") {
try {
execSync(`chmod +x ${nativeLibPath}`);
} catch (e) {
console.error(`Unable to chmod, engine may fail. Error: ${e}`);
}
}
var engineCall = spawn(nativeLibPath, [engineCallArgs]);
this.engineCall = engineCall;
engineCall.stdout.on('data', (data) => {
console.log(`Engine stdout: ${data}`);
});
engineCall.stderr.on('data', (data) => {
console.log(`Engine stderr: ${data}`);
});
engineCall.on('close', async (code) => {
if (this.state == 4) {
//nop
} else {
console.log(`Engine closed with exit code: ${code}`);
this.engineCall = null;
this.engineFailed("Engine exited abnormally");
throw new Error("Engine exited abnormally"); // safer than process.exit()
}
});
},
engineFailed: function(msg) {
if (this.executor != null) {
this.executor.reject(msg);
}
this.state = 3;
this.executor = null; // but I keep the serverPromise so to reject new incoming requests.
},
engineReady: function(port) {
if (this.state != 1) {
console.log("readyReceived AGAIN?!");
return;
}
console.log("RevealServer ready.");
this.enginePort = port;
this.state = 2;
this.serverPromise = null;
var executor = this.executor;
this.executor = null;
executor.resolve();
},
process: function(req, res) {
this.doWhenServerReady(
() => this.doProcess(req, res),
() => {
res.statusCode = 503;
res.end();
});
},
doWhenServerReady(callback, errorCallback) {
if (this.state == 2) {
callback();
} else if (this.state == 4) {
console.log("Engine was requested to do work after it has been stopped. InstanceId " + this.instanceUUID);
errorCallback();
} else { // this includes handling of state == engine dead, will catch immediately and err.
this.serverPromise
.then(_ => callback())
.catch(_ => errorCallback());
}
},
doProcess: function(req, res) {
this.addReqErrorHandler(req, res, "Request processing failed");
this.addResErrorHandler(res, "Response processing failed");
var parsedUrl = url.parse(req.url);
if (this.options.basePath) {
parsedUrl.path = parsedUrl.path.substring(1 + this.options.basePath.length);
}
if (parsedUrl.path == '/tools/renderhtml/') {
this.processRenderHtml(req, res);
return;
}
req.pause();
var userContext = this.options.userContextProvider?.(req);
var connector = this.httpRequestToEngineEndpoint(
req.method,
parsedUrl.path,
parsedUrl.query,
req.headers, // we're just passing the incoming headers. Do we want to alter/remove any of them?
userContext,
(serverResponse) => {
serverResponse.pause();
res.writeHead(serverResponse.statusCode, serverResponse.headers);
serverResponse.pipe(res, { end: true });
serverResponse.resume();
},
e => this.handleError(e, 'Engine invocation failed', res));
req.pipe(connector, { end: true });
req.resume();
},
bridge: function(endpointMethod, endpointPath, endpointQuery, body, endpointHeaders, userContext) {
return new Promise((resolve, reject) => {
this.doWhenServerReady(
() => {
let request = this.httpRequestToEngineEndpoint(endpointMethod, endpointPath, endpointQuery, endpointHeaders, userContext,
(serverResponse) => {
let hasErrors = serverResponse.statusCode == 500;
let chunks = [];
serverResponse.on('data', (chunk) => {
chunks.push(chunk);
}).on('end', () => {
let bodyStr = Buffer.concat(chunks).toString();
if(hasErrors)
reject(bodyStr);
else
resolve(bodyStr);
}); // not sure if we need to listen for 'error' on the serverResponse. We don't do that on doProcess.
},
reject);
request.write(body);
request.end();
},
() => {
reject(new Error("Can't process the request. See previous error messages."));
});
});
},
httpRequestToEngineEndpoint: function(endpointMethod, endpointPath, endpointQuery, endpointHeaders, userContext, callback, errorCallback) {
var userContextId = randomUUID();
this.userContexts[userContextId] = userContext;
var headers = {"RevealBI-UserId": userContextId};
Object.assign(headers, endpointHeaders);
var connector = http.request({
'protocol': 'http:',
'host': '127.0.0.1',
'port': this.enginePort,
'path': endpointPath,
'query': endpointQuery,
'method': endpointMethod,
'headers': headers
}, function(serverResponse) {
callback(serverResponse);
});
connector.on('close', () => {
delete this.userContexts[userContextId];
});
connector.on('error', errorCallback);
return connector; // Note that either end or abort needs to be called on this one by the caller, otherwise we would be leaking nodejs resources.
},
addReqErrorHandler: function(req, res, msg) {
req.on("error", e => this.handleError(e, msg, res));
},
addResErrorHandler: function(res, msg) {
res.on("error", e => this.handleError(e, msg, res));
},
handleError: function(e, msg, res) {
var uuid = randomUUID();
console.error(`${msg}: ${e}\n${e.stack}. Request #${uuid}`);
if (!res.headersSent) {
res.statusCode = 500;
res.end(`Failed. Request ${uuid}`);
} else {
res.end();
}
},
processRenderHtml: async function(req, res) {
var input = await streamToString(req);
var tmpHtml = await this.ensureRenderHtmlTemplate();
tmpHtml = tmpHtml
+ input.replace(/<canvas imgsrc/gi, "<img src").replace(/\/canvas>/gi, "/img>");
+ "</body></html>";
var dims = this.extractDimensions(input);
var imageStr;
try {
imageStr = await this.takeScreenshot(tmpHtml, dims);
} catch (e) {
var uuid = randomUUID();
console.error(`Failed to take screenshot: ${e}\n${e.stack}. Request #${uuid}`);
res.statusCode = 500;
res.end(`Failed. Request ${uuid}`);
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(imageStr);
},
extractDimensions: function(html) {
return {
'width': this.extractSize(html, 'width', 1024),
'height': this.extractSize(html, 'height', 768)
};
},
extractSize: function(html, type, defaultValue) {
var wPos = html.indexOf(type);
if (wPos < 0) return defaultValue;
wPos += type.length + 2;
var pxIndex = html.indexOf("px", wPos);
var substring = html.substring(wPos, pxIndex);
var parsed = parseInt(substring);
return parsed;
},
takeScreenshot: async function(html, dims) {
const browser = await this.ensureScreenshotBrowser();
var page = null;
try{
page = await browser.newPage();
await page.setViewport(dims);
await page.setContent(html);
var result = await page.screenshot({ encoding: 'base64' });
return result;
}finally{
if(page != null && !page.isClosed())
page.close();
}
},
ensureRenderHtmlTemplate: function() {
return this.loadOnce(async function() {
return (await fs.promises.readFile(__dirname + path.sep + "renderHtmlTemplate.html")).toString();
}, 'renderHtmltemplate');
},
ensureScreenshotBrowser: async function() {
return this.loadOnce(async () => {
var executablePath = this.options.advancedSettings?.get('puppeteer_executable_path');
if (executablePath === undefined) {
try
{
executablePath = await puppeteerUtils.download();
}
catch(error)
{
console.log(error);
}
}
if (!executablePath) {
console.error("Couldn't find chromium executable");
}
var launchArgs = [];
if (os.platform() == "linux" && process.getuid() == 0 /* is root */) {
launchArgs.push('--no-sandbox'); // Running as root without --no-sandbox is not supported. See https://crbug.com/638180
}
return await puppeteer.launch({ executablePath: executablePath, args: launchArgs});
}, 'screenshotBrowser');
},
loadOnce: async function(loader, name) {
var lazyLoads = this.lazyLoads ?? (this.lazyLoads = {});
var loaded = lazyLoads[name];
if (loaded) return loaded;
var promiseName = name + "_Promise";
var promise = lazyLoads[promiseName];
if (promise) return promise;
promise = lazyLoads[promiseName] = loader();
return promise;
}
};
srv.serverPromise = new Promise((resolve, reject) => srv.executor = { 'resolve': resolve, 'reject': reject } );
srv.serverPromise.catch(r =>
{
console.log(`Engine failed to start. ${r}`);
});
return srv;
};
async function warmup() {
await createServer().ensureScreenshotBrowser();
}
async function streamToString(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf-8");
}
class IRVDashboardCredential {
toJson() {}
}
class RVUsernamePasswordDataSourceCredential extends IRVDashboardCredential {
constructor(userName, password, domain) {
super();
this._userName = userName;
this._password = password;
this._domain = domain;
}
get userName() {
return this._userName;
}
get password() {
return this._password;
}
get domain() {
return this._domain;
}
toJson() {
return {
"type": "usernamePassword",
"username": this._userName,
"password": this._password,
"domain": this.domain
}
}
}
class RVBearerTokenDataSourceCredential extends IRVDashboardCredential {
constructor(token, userId) {
super();
this._token = token;
this._userId = userId;
}
get token() {
return this._token;
}
get userId() {
return this._userId;
}
toJson() {
return {
"type": "bearerToken",
"token": this._token,
"userId": this._userId
}
}
}
class RVHeadersDataSourceCredentials extends IRVDashboardCredential {
constructor() {
super();
if (arguments.length === 1) {
this._headers = arguments[0] || new Map();
} else if (arguments.length === 2) {
this._headers = new Map();
this.addHeader(arguments[0], arguments[1])
} else {
this._headers = new Map();
}
}
get headers() {
return this._headers;
}
addHeader(key, value) {
if (key && value) {
this._headers.set(key, value);
}
}
toJson() {
const headers = Object.fromEntries(this._headers)
return {
"type": "headers",
headers
}
}
}
class RVAmazonWebServicesCredentials extends IRVDashboardCredential {
constructor(key, secret, sessionToken) {
super();
this._key = key;
this._secret = secret;
this._sessionToken = sessionToken;
}
get key() {
return this._key;
}
get secret() {
return this._secret;
}
get sessionToken() {
return this._sessionToken;
}
toJson() {
return {
"type": "amazonWS",
"key": this._key,
"secret": this._secret,
"sessionToken": this.sessionToken
}
}
}
class RVIntegratedAuthenticationCredential extends IRVDashboardCredential {
constructor() {
super();
}
toJson() {
return {
"type": "integrated"
}
}
}
class RVPersonalAccessTokenDataSourceCredential extends IRVDashboardCredential {
constructor(token) {
super();
this._token = token;
}
get token() {
return this._token;
}
toJson() {
return {
"type": "pat",
"token": this.token
}
}
}
class RVOAuthDataSourceCredential extends IRVDashboardCredential {
constructor(clientId, clientSecret) {
super();
this._clientId = clientId;
this._clientSecret = clientSecret;
}
get clientId() {
return this._clientId;
}
get clientSecret() {
return this._clientSecret;
}
toJson() {
return {
"type": "twoLeggedOAuth",
"clientId": this._clientId,
"clientSecret": this._clientSecret,
}
}
}
class RVMicrosoftEntraIDDataSourceCredential extends IRVDashboardCredential {
constructor(tenantId, clientId, clientSecret) {
super();
this._tenantId = tenantId;
this._clientId = clientId;
this._clientSecret = clientSecret;
}
get tenantId() {
return this._tenantId;
}
get clientId() {
return this._clientId;
}
get clientSecret() {
return this._clientSecret;
}
toJson() {
return {
"type": "microsoftEntraID",
"tenantId": this._tenantId,
"clientId": this._clientId,
"clientSecret": this._clientSecret,
}
}
}
class RVKeyPairDataSourceCredential extends IRVDashboardCredential {
constructor(userName, privateKey) {
super();
this._userName = userName;
this._privateKey = privateKey;
}
get userName() {
return this._userName;
}
get privateKey() {
return this._privateKey;
}
toJson() {
return {
"type": "keyPair",
"userName": this._userName,
"privateKey": this._privateKey,
}
}
}
class IRVUserContext {
get userId() {
return null;
}
get properties() {
return null;
}
}
class RVUserContext extends IRVUserContext {
constructor(userId, properties) {
super();
this._userId = userId;
this._properties = properties;
}
get userId() {
return this._userId;
}
get properties() {
return this._properties;
}
}
const me = create;
// Credentials:
me.RVUsernamePasswordDataSourceCredential = RVUsernamePasswordDataSourceCredential;
me.RVBearerTokenDataSourceCredential = RVBearerTokenDataSourceCredential;
me.RVHeadersDataSourceCredentials = RVHeadersDataSourceCredentials;
me.RVAmazonWebServicesCredentials = RVAmazonWebServicesCredentials;
me.RVIntegratedAuthenticationCredential = RVIntegratedAuthenticationCredential;
me.RVPersonalAccessTokenDataSourceCredential = RVPersonalAccessTokenDataSourceCredential;
me.RVOAuthDataSourceCredential = RVOAuthDataSourceCredential;
me.RVMicrosoftEntraIDDataSourceCredential = RVMicrosoftEntraIDDataSourceCredential;
me.RVKeyPairDataSourceCredential = RVKeyPairDataSourceCredential;
// DS
Object.assign(me, dsExports);
//
me.IRVUserContext = IRVUserContext;
me.RVUserContext = RVUserContext;
me.warmup = warmup;
me.revealCore = create;
module.exports = me;