@jsreport/jsreport-core
Version:
javascript based business reporting
359 lines (288 loc) • 12.4 kB
JavaScript
/*!
* Copyright(c) 2018 Jan Blaha
*
* Child process script rendering html from template content, helpers and input data.
* This script runs in the extra process because of multitenancy and security requirements, errors like infinite loop
* should not affect other reports being rendered at the same time
*/
const LRU = require('lru-cache')
const { nanoid } = require('nanoid')
module.exports = (reporter) => {
const templatesCache = LRU(reporter.options.sandbox.cache)
let systemHelpersCache
reporter.templatingEngines = { cache: templatesCache }
const executionFnParsedParamsMap = new Map()
const executionAsyncResultsMap = new Map()
const templatingEnginesEvaluate = async (mainCall, { engine, content, helpers, data }, { entity, entitySet }, req) => {
const engineImpl = reporter.extensionsManager.engines.find((e) => e.name === engine)
if (!engine) {
throw reporter.createError(`Engine '${engine}' not found. If this is a custom engine make sure it's properly installed from npm`, {
statusCode: 400
})
}
if (mainCall) {
executionFnParsedParamsMap.set(req.context.id, new Map())
}
const executionId = nanoid(7)
try {
const res = await executeEngine({
engine: engineImpl,
content,
helpers,
data
}, { executionId, handleErrors: false, entity, entitySet }, req)
return res.content
} finally {
if (mainCall) {
executionFnParsedParamsMap.delete(req.context.id)
}
executionAsyncResultsMap.delete(executionId)
}
}
reporter.templatingEngines.evaluate = (executionInfo, entityInfo, req) => templatingEnginesEvaluate(true, executionInfo, entityInfo, req)
reporter.extendProxy((proxy, req, {
runInSandbox,
context,
getTopLevelFunctions
}) => {
proxy.templatingEngines = {
evaluate: async (executionInfo, entityInfo) => {
return templatingEnginesEvaluate(false, executionInfo, entityInfo, req)
},
waitForAsyncHelper: async (maybeAsyncContent) => {
if (
context.__executionId == null ||
!executionAsyncResultsMap.has(context.__executionId) ||
typeof maybeAsyncContent !== 'string'
) {
return maybeAsyncContent
}
const asyncResultMap = executionAsyncResultsMap.get(context.__executionId)
const asyncHelperResultRegExp = /{#asyncHelperResult ([^{}]+)}/
let content = maybeAsyncContent
let matchResult
do {
if (matchResult != null) {
const matchedPart = matchResult[0]
const asyncResultId = matchResult[1]
const result = await asyncResultMap.get(asyncResultId)
content = `${content.slice(0, matchResult.index)}${result}${content.slice(matchResult.index + matchedPart.length)}`
}
matchResult = content.match(asyncHelperResultRegExp)
} while (matchResult != null)
return content
},
waitForAsyncHelpers: async () => {
if (context.__executionId != null && executionAsyncResultsMap.has(context.__executionId)) {
const asyncResultMap = executionAsyncResultsMap.get(context.__executionId)
return Promise.all([...asyncResultMap.keys()].map((k) => asyncResultMap.get(k)))
}
},
createAsyncHelperResult: (v) => {
const asyncResultMap = executionAsyncResultsMap.get(context.__executionId)
const asyncResultId = nanoid(7)
asyncResultMap.set(asyncResultId, v)
return `{#asyncHelperResult ${asyncResultId}}`
}
}
})
return async (engine, req) => {
executionFnParsedParamsMap.set(req.context.id, new Map())
req.data.__appDirectory = reporter.options.appDirectory
req.data.__rootDirectory = reporter.options.rootDirectory
req.data.__parentModuleDirectory = reporter.options.parentModuleDirectory
const executionId = nanoid(7)
try {
return await executeEngine({
engine,
content: req.template.content,
helpers: req.template.helpers,
data: req.data
}, {
executionId,
handleErrors: true,
entity: req.template,
entitySet: 'templates'
}, req)
} finally {
executionFnParsedParamsMap.delete(req.context.id)
executionAsyncResultsMap.delete(executionId)
}
}
async function executeEngine ({ engine, content, helpers, data }, { executionId, handleErrors, entity, entitySet }, req) {
let entityPath
if (entity._id) {
entityPath = await reporter.folders.resolveEntityPath(entity, entitySet, req)
}
const normalizedHelpers = `${helpers || ''}`
const executionFnParsedParamsKey = `entity:${entity.shortid || 'anonymous'}:helpers:${normalizedHelpers}`
const initFn = async (getTopLevelFunctions, compileScript) => {
if (systemHelpersCache != null) {
return systemHelpersCache
}
const registerResults = await reporter.registerHelpersListeners.fire()
const systemHelpers = []
for (const result of registerResults) {
if (result == null) {
continue
}
if (typeof result === 'string') {
systemHelpers.push(result)
}
}
const systemHelpersStr = systemHelpers.join('\n')
const functionNames = getTopLevelFunctions(systemHelpersStr)
const exposeSystemHelpersCode = `for (const fName of ${JSON.stringify(functionNames)}) { this[fName] = __topLevelFunctions[fName] }`
// we sync the __topLevelFunctions with system helpers and expose it immediately to the global context
const userCode = `(async () => { ${systemHelpersStr};
__topLevelFunctions = {...__topLevelFunctions, ${functionNames.map(h => `"${h}": ${h}`).join(',')}}; ${exposeSystemHelpersCode}
})()`
const filename = 'system-helpers.js'
const script = compileScript(userCode, filename)
systemHelpersCache = {
filename,
source: systemHelpersStr,
script
}
return systemHelpersCache
}
const executionFn = async ({ require, console, topLevelFunctions, context }) => {
const asyncResultMap = new Map()
context.__executionId = executionId
executionAsyncResultsMap.set(executionId, asyncResultMap)
executionFnParsedParamsMap.get(req.context.id).get(executionFnParsedParamsKey).resolve({ require, console, topLevelFunctions, context })
const key = `template:${content}:${engine.name}`
if (!templatesCache.has(key)) {
try {
templatesCache.set(key, engine.compile(content, { require }))
} catch (e) {
e.property = 'content'
throw e
}
}
const compiledTemplate = templatesCache.get(key)
const wrappedTopLevelFunctions = {}
for (const h of Object.keys(topLevelFunctions)) {
// extra wrapping for enhance the error with the helper name
wrappedTopLevelFunctions[h] = wrapHelperForHelperNameWhenError(topLevelFunctions[h], h)
if (engine.getWrappingHelpersEnabled && engine.getWrappingHelpersEnabled(req) === false) {
wrappedTopLevelFunctions[h] = engine.wrapHelper(wrappedTopLevelFunctions[h], { context })
} else {
wrappedTopLevelFunctions[h] = wrapHelperForAsyncSupport(wrappedTopLevelFunctions[h], asyncResultMap)
}
}
let contentResult = await engine.execute(compiledTemplate, wrappedTopLevelFunctions, data, { require })
const resolvedResultsMap = new Map()
while (asyncResultMap.size > 0) {
await Promise.all([...asyncResultMap.keys()].map(async (k) => {
resolvedResultsMap.set(k, `${await asyncResultMap.get(k)}`)
asyncResultMap.delete(k)
}))
}
while (contentResult.includes('{#asyncHelperResult')) {
contentResult = contentResult.replace(/{#asyncHelperResult ([^{}]+)}/g, (str, p1) => {
const asyncResultId = p1
return `${resolvedResultsMap.get(asyncResultId)}`
})
}
return {
// handlebars escapes single brackets before execution to prevent errors on {#asset}
// we need to unescape them later here, because at the moment the engine.execute finishes
// the async helpers aren't executed yet
content: engine.unescape ? engine.unescape(contentResult) : contentResult
}
}
// executionFnParsedParamsMap is there to cache parsed components helpers to speed up longer loops
// we store there for the particular request and component a promise and only the first component gets compiled
if (executionFnParsedParamsMap.get(req.context.id).has(executionFnParsedParamsKey)) {
const { require, console, topLevelFunctions, context } = await (executionFnParsedParamsMap.get(req.context.id).get(executionFnParsedParamsKey).promise)
return executionFn({ require, console, topLevelFunctions, context })
} else {
const awaiter = {}
awaiter.promise = new Promise((resolve) => {
awaiter.resolve = resolve
})
executionFnParsedParamsMap.get(req.context.id).set(executionFnParsedParamsKey, awaiter)
}
if (reporter.options.sandbox.cache && reporter.options.sandbox.cache.enabled === false) {
templatesCache.reset()
}
try {
return await reporter.runInSandbox({
context: {
...(engine.createContext ? engine.createContext(req) : {})
},
userCode: normalizedHelpers,
initFn,
executionFn,
currentPath: entityPath,
onRequire: (moduleName, { context }) => {
if (engine.onRequire) {
return engine.onRequire(moduleName, { context })
}
}
}, req)
} catch (e) {
if (!handleErrors) {
throw e
}
const nestedErrorWithEntity = e.entity != null
const templatePath = req.template._id ? await reporter.folders.resolveEntityPath(req.template, 'templates', req) : 'anonymous'
const newError = reporter.createError(`Error when evaluating engine ${engine.name} for template ${templatePath}`, { original: e })
if (templatePath !== 'anonymous' && !nestedErrorWithEntity) {
const templateFound = await reporter.folders.resolveEntityFromPath(templatePath, 'templates', req)
if (templateFound != null) {
newError.entity = {
shortid: templateFound.entity.shortid,
name: templateFound.entity.name,
content
}
}
}
if (!nestedErrorWithEntity && e.property !== 'content') {
newError.property = 'helpers'
}
if (nestedErrorWithEntity) {
// errors from nested assets evals needs an unwrap for some reason
newError.entity = { ...e.entity }
}
// we remove the decoratedSuffix (created from sandbox) from the stack trace (if it is there) because it
// just creates noise and duplication when printing the error,
// we just want the decoration on the message not in the stack trace
if (e.decoratedSuffix != null && newError.stack.includes(e.decoratedSuffix)) {
newError.stack = newError.stack.replace(e.decoratedSuffix, '')
}
throw newError
}
}
function wrapHelperForAsyncSupport (fn, asyncResultMap) {
return function (...args) {
// important to call the helper with the current this to preserve the same behavior
const fnResult = fn.call(this, ...args)
if (fnResult == null || typeof fnResult.then !== 'function') {
return fnResult
}
const asyncResultId = nanoid(7)
asyncResultMap.set(asyncResultId, fnResult)
return `{#asyncHelperResult ${asyncResultId}}`
}
}
function wrapHelperForHelperNameWhenError (fn, helperName) {
return function (...args) {
let fnResult
const getEnhancedHelperError = (e) => reporter.createError(`"${helperName}" helper call failed`, { original: e })
try {
// important to call the helper with the current this to preserve the same behavior
fnResult = fn.call(this, ...args)
} catch (syncError) {
throw getEnhancedHelperError(syncError)
}
if (fnResult == null || typeof fnResult.then !== 'function') {
return fnResult
}
return fnResult.catch((asyncError) => {
throw getEnhancedHelperError(asyncError)
})
}
}
}