UNPKG

reveal-sdk-node

Version:

RevealBI Node.js SDK

892 lines (798 loc) 26.8 kB
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(`@revealbi/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;