@jsreport/jsreport-core 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,8 +13,34 @@ module.exports.ensureTempDirectoryExists = async function (tempDirectory) {
13
13
  }
14
14
  }
15
15
 
16
+ module.exports.getTempFilePath = getTempFilePath
17
+
18
+ module.exports.readTempFileSync = function readTempFileSync (tempDirectory, filename, opts = {}) {
19
+ const { pathToFile } = getTempFilePath(tempDirectory, filename)
20
+
21
+ const content = fs.readFileSync(pathToFile, opts)
22
+
23
+ return {
24
+ pathToFile,
25
+ filename,
26
+ content
27
+ }
28
+ }
29
+
30
+ module.exports.openTempFile = async function (tempDirectory, filenameFn, flags) {
31
+ const { pathToFile, filename } = getTempFilePath(tempDirectory, filenameFn)
32
+
33
+ const fileHandle = await fsAsync.open(pathToFile, flags)
34
+
35
+ return {
36
+ pathToFile,
37
+ filename,
38
+ fileHandle
39
+ }
40
+ }
41
+
16
42
  module.exports.readTempFile = async function readTempFile (tempDirectory, filename, opts = {}) {
17
- const pathToFile = path.join(tempDirectory, filename)
43
+ const { pathToFile } = getTempFilePath(tempDirectory, filename)
18
44
 
19
45
  const content = await fsAsync.readFile(pathToFile, opts)
20
46
 
@@ -25,51 +51,91 @@ module.exports.readTempFile = async function readTempFile (tempDirectory, filena
25
51
  }
26
52
  }
27
53
 
28
- module.exports.readTempFileStream = async function readTempFileStream (tempDirectory, filename, opts = {}) {
29
- const pathToFile = path.join(tempDirectory, filename)
54
+ module.exports.readTempFileStream = function readTempFileStream (tempDirectory, filename, opts = {}) {
55
+ const { pathToFile } = getTempFilePath(tempDirectory, filename)
30
56
 
31
- return new Promise((resolve) => {
32
- const stream = fs.createReadStream(pathToFile, opts)
57
+ const stream = fs.createReadStream(pathToFile, opts)
33
58
 
34
- resolve({
35
- pathToFile,
36
- filename,
37
- stream
38
- })
39
- })
59
+ return {
60
+ pathToFile,
61
+ filename,
62
+ stream
63
+ }
64
+ }
65
+
66
+ module.exports.writeTempFileSync = function writeTempFileSync (tempDirectory, filenameOrFn, content, opts = {}) {
67
+ return writeFileSync(tempDirectory, filenameOrFn, content, opts)
40
68
  }
41
69
 
42
- module.exports.writeTempFile = async function writeTempFile (tempDirectory, filenameFn, content, opts = {}) {
43
- return writeFile(tempDirectory, filenameFn, content, opts)
70
+ module.exports.writeTempFile = async function writeTempFile (tempDirectory, filenameOrFn, content, opts = {}) {
71
+ return writeFile(tempDirectory, filenameOrFn, content, opts)
44
72
  }
45
73
 
46
- module.exports.writeTempFileStream = async function writeTempFileStream (tempDirectory, filenameFn, opts = {}) {
47
- return writeFile(tempDirectory, filenameFn, undefined, opts, true)
74
+ module.exports.writeTempFileStream = async function writeTempFileStream (tempDirectory, filenameOrFn, opts = {}) {
75
+ return writeFile(tempDirectory, filenameOrFn, undefined, opts, true)
48
76
  }
49
77
 
50
- async function writeFile (tempDirectory, filenameFn, content, opts, asStream = false) {
51
- const filename = filenameFn(uuidv4())
78
+ module.exports.copyFileToTempFile = async function copyFileToTempFile (tempDirectory, srcFilePath, destFilenameOrFn, mode) {
79
+ const { pathToFile, filename } = getTempFilePath(tempDirectory, destFilenameOrFn)
52
80
 
53
- if (filename == null || filename === '') {
54
- throw new Error('No valid filename was returned from filenameFn')
81
+ await fsAsync.mkdir(tempDirectory, {
82
+ recursive: true
83
+ })
84
+
85
+ await fsAsync.copyFile(srcFilePath, pathToFile, mode)
86
+
87
+ return {
88
+ pathToFile,
89
+ filename
90
+ }
91
+ }
92
+
93
+ function getTempFilePath (tempDirectory, filenameOrFn) {
94
+ const filenameResult = typeof filenameOrFn === 'function' ? filenameOrFn(uuidv4()) : filenameOrFn
95
+
96
+ if (filenameResult == null || filenameResult === '') {
97
+ throw new Error('No valid filename')
98
+ }
99
+
100
+ const pathToFile = path.isAbsolute(filenameResult) ? filenameResult : path.join(tempDirectory, filenameResult)
101
+ const filename = path.basename(pathToFile)
102
+
103
+ return {
104
+ pathToFile,
105
+ filename
106
+ }
107
+ }
108
+
109
+ function writeFileSync (tempDirectory, filenameOrFn, content, opts) {
110
+ const { pathToFile, filename } = getTempFilePath(tempDirectory, filenameOrFn)
111
+
112
+ fs.mkdirSync(tempDirectory, {
113
+ recursive: true
114
+ })
115
+
116
+ fs.writeFileSync(pathToFile, content, opts)
117
+
118
+ return {
119
+ pathToFile,
120
+ filename
55
121
  }
122
+ }
56
123
 
57
- const pathToFile = path.join(tempDirectory, filename)
124
+ async function writeFile (tempDirectory, filenameOrFn, content, opts, asStream = false) {
125
+ const { pathToFile, filename } = getTempFilePath(tempDirectory, filenameOrFn)
58
126
 
59
127
  await fsAsync.mkdir(tempDirectory, {
60
128
  recursive: true
61
129
  })
62
130
 
63
131
  if (asStream === true) {
64
- return new Promise((resolve) => {
65
- const stream = fs.createWriteStream(pathToFile, opts)
66
-
67
- resolve({
68
- pathToFile,
69
- filename,
70
- stream
71
- })
72
- })
132
+ const stream = fs.createWriteStream(pathToFile, opts)
133
+
134
+ return {
135
+ pathToFile,
136
+ filename,
137
+ stream
138
+ }
73
139
  } else {
74
140
  await fsAsync.writeFile(pathToFile, content, opts)
75
141
 
@@ -12,10 +12,8 @@ module.exports = (reporter) => (proxy, req) => {
12
12
  context: {}
13
13
  }, req)
14
14
 
15
- return {
16
- content: res.content,
17
- meta: res.meta
18
- }
15
+ // expose the response api
16
+ return res
19
17
  }
20
18
 
21
19
  proxy.documentStore = {
@@ -0,0 +1,97 @@
1
+ const bytes = require('bytes')
2
+ const { Readable } = require('stream')
3
+ /*
4
+ This adds jsreport.templatingEngines.createStream to the helpers proxy allowing to write giant texts to output
5
+ which would otherwise hit nodejs max string size limit.
6
+
7
+ Example usage
8
+ ===================
9
+ async function myEach(items, options) {
10
+ const stream = await jsreport.templatingEngines.createStream()
11
+ for (let i = 0; i < items.length; i++) {
12
+ await stream.write(options.fn())
13
+ }
14
+ return await stream.toResult()
15
+ }
16
+ */
17
+
18
+ module.exports = (reporter) => {
19
+ reporter.afterTemplatingEnginesExecutedListeners.add('streamedEach', async (req, res) => {
20
+ if (req.context.engineStreamEnabled !== true) {
21
+ return
22
+ }
23
+
24
+ const content = (await res.output.getBuffer()).toString()
25
+
26
+ const matches = [...content.matchAll(/{#stream ([^{}]{0,500})}/g)]
27
+
28
+ async function * transform () {
29
+ if (matches.length) {
30
+ yield content.substring(0, matches[0].index)
31
+
32
+ for (let i = 0; i < matches.length; i++) {
33
+ const { stream } = reporter.readTempFileStream(matches[i][1])
34
+
35
+ for await (const content of stream) {
36
+ yield content
37
+ }
38
+
39
+ if (i < matches.length - 1) {
40
+ yield content.substring(matches[i].index + matches[i][0].length, matches[i + 1].index)
41
+ } else {
42
+ yield content.substring(matches[i].index + matches[i][0].length)
43
+ }
44
+ }
45
+ } else {
46
+ yield content
47
+ }
48
+ }
49
+
50
+ await res.output.update(Readable.from(transform()))
51
+ })
52
+
53
+ reporter.extendProxy((proxy, req, {
54
+ runInSandbox,
55
+ context,
56
+ getTopLevelFunctions
57
+ }) => {
58
+ if (proxy.templatingEngines) {
59
+ proxy.templatingEngines.createStream = async (opts = {}) => {
60
+ // limiting the number of temp files to avoid breaking server, otherwise I see no reason why having more than 1000 calls per req should be valid usecase
61
+ const counter = reporter.reqStorage.get('engine-stream-counter', req) || 0
62
+ if (counter > 1000) {
63
+ throw reporter.createError('Reached maximum limit of templatingEngine.createStream calls', {
64
+ weak: true,
65
+ statusCode: 400
66
+ })
67
+ }
68
+ reporter.reqStorage.set('engine-stream-counter', counter + 1, req)
69
+
70
+ req.context.engineStreamEnabled = true
71
+
72
+ const bufferSize = bytes(opts.bufferSize || '10mb')
73
+ let buf = ''
74
+
75
+ const { fileHandle, filename } = await reporter.openTempFile((uuid) => `${uuid}.stream`, 'a')
76
+ proxy.templatingEngines.addFinishListener(() => fileHandle.close().catch((e) => reporter.logger.error('Failed to close temp file handle', e, req)))
77
+
78
+ return {
79
+ write: async (text) => {
80
+ const realText = await proxy.templatingEngines.waitForAsyncHelper(text)
81
+
82
+ buf += realText
83
+
84
+ if (buf.length > bufferSize) {
85
+ await fileHandle.appendFile(buf)
86
+ buf = ''
87
+ }
88
+ },
89
+ toResult: async () => {
90
+ await fileHandle.appendFile(buf)
91
+ return `{#stream ${filename}}`
92
+ }
93
+ }
94
+ }
95
+ }
96
+ })
97
+ }
@@ -16,6 +16,7 @@ module.exports = (reporter) => {
16
16
 
17
17
  const executionFnParsedParamsMap = new Map()
18
18
  const executionAsyncResultsMap = new Map()
19
+ const executionFinishListenersMap = new Map()
19
20
 
20
21
  const templatingEnginesEvaluate = async (mainCall, { engine, content, helpers, data }, { entity, entitySet }, req) => {
21
22
  const engineImpl = reporter.extensionsManager.engines.find((e) => e.name === engine)
@@ -47,6 +48,7 @@ module.exports = (reporter) => {
47
48
  }
48
49
 
49
50
  executionAsyncResultsMap.delete(executionId)
51
+ executionFinishListenersMap.delete(executionId)
50
52
  }
51
53
  }
52
54
 
@@ -94,6 +96,11 @@ module.exports = (reporter) => {
94
96
  return Promise.all([...asyncResultMap.keys()].map((k) => asyncResultMap.get(k)))
95
97
  }
96
98
  },
99
+ addFinishListener: (fn) => {
100
+ if (executionFinishListenersMap.has(context.__executionId)) {
101
+ executionFinishListenersMap.get(context.__executionId).add('finish', fn)
102
+ }
103
+ },
97
104
  createAsyncHelperResult: (v) => {
98
105
  const asyncResultMap = executionAsyncResultsMap.get(context.__executionId)
99
106
  const asyncResultId = nanoid(7)
@@ -190,9 +197,12 @@ module.exports = (reporter) => {
190
197
  context.__executionId = executionId
191
198
 
192
199
  executionAsyncResultsMap.set(executionId, asyncResultMap)
200
+ executionFinishListenersMap.set(executionId, reporter.createListenerCollection())
193
201
  executionFnParsedParamsMap.get(req.context.id).get(executionFnParsedParamsKey).resolve({ require, console, topLevelFunctions, context })
194
202
 
195
- const key = `template:${content}:${engine.name}`
203
+ const key = engine.buildTemplateCacheKey
204
+ ? engine.buildTemplateCacheKey({ content }, req)
205
+ : `template:${content}:${engine.name}`
196
206
 
197
207
  if (!templatesCache.has(key)) {
198
208
  try {
@@ -208,7 +218,7 @@ module.exports = (reporter) => {
208
218
 
209
219
  for (const h of Object.keys(topLevelFunctions)) {
210
220
  // extra wrapping for enhance the error with the helper name
211
- wrappedTopLevelFunctions[h] = wrapHelperForHelperNameWhenError(topLevelFunctions[h], h)
221
+ wrappedTopLevelFunctions[h] = wrapHelperForHelperNameWhenError(topLevelFunctions[h], h, () => executionFnParsedParamsMap.has(req.context.id))
212
222
 
213
223
  if (engine.getWrappingHelpersEnabled && engine.getWrappingHelpersEnabled(req) === false) {
214
224
  wrappedTopLevelFunctions[h] = engine.wrapHelper(wrappedTopLevelFunctions[h], { context })
@@ -221,19 +231,33 @@ module.exports = (reporter) => {
221
231
 
222
232
  const resolvedResultsMap = new Map()
223
233
 
224
- while (asyncResultMap.size > 0) {
225
- await Promise.all([...asyncResultMap.keys()].map(async (k) => {
226
- resolvedResultsMap.set(k, `${await asyncResultMap.get(k)}`)
227
- asyncResultMap.delete(k)
234
+ // we need to use the cloned map, becuase there can be a waitForAsyncHelper pending that needs the asyncResultMap values
235
+ const clonedMap = new Map(asyncResultMap)
236
+ while (clonedMap.size > 0) {
237
+ await Promise.all([...clonedMap.keys()].map(async (k) => {
238
+ resolvedResultsMap.set(k, `${await clonedMap.get(k)}`)
239
+ clonedMap.delete(k)
228
240
  }))
229
241
  }
242
+ asyncResultMap.clear()
230
243
 
231
244
  while (contentResult.includes('{#asyncHelperResult')) {
232
245
  contentResult = contentResult.replace(/{#asyncHelperResult ([^{}]+)}/g, (str, p1) => {
233
246
  const asyncResultId = p1
247
+ // this can happen if a child jsreport.templatingEngines.evaluate receives an async value from outer scope
248
+ // because every evaluate uses a unique map of async resuts
249
+ // example is the case when component receives as a value async thing
250
+ // instead of returning "undefined" we let the outer eval to do the replace
251
+ if (!resolvedResultsMap.has(asyncResultId)) {
252
+ // returning asyncUnresolvedHelperResult just to avoid endless loop, after replace we put it back to asyncHelperResult
253
+ return `{#asyncUnresolvedHelperResult ${asyncResultId}}`
254
+ }
234
255
  return `${resolvedResultsMap.get(asyncResultId)}`
235
256
  })
236
257
  }
258
+ contentResult = contentResult.replace(/asyncUnresolvedHelperResult/g, 'asyncHelperResult')
259
+
260
+ await executionFinishListenersMap.get(context.__executionId).fire()
237
261
 
238
262
  return {
239
263
  // handlebars escapes single brackets before execution to prevent errors on {#asset}
@@ -355,7 +379,7 @@ module.exports = (reporter) => {
355
379
  }
356
380
  }
357
381
 
358
- function wrapHelperForHelperNameWhenError (fn, helperName) {
382
+ function wrapHelperForHelperNameWhenError (fn, helperName, isMainEvalStillRunningFn) {
359
383
  return function (...args) {
360
384
  let fnResult
361
385
 
@@ -373,6 +397,10 @@ module.exports = (reporter) => {
373
397
  }
374
398
 
375
399
  return fnResult.catch((asyncError) => {
400
+ if (!isMainEvalStillRunningFn()) {
401
+ // main exec already finished on some error, we just ignore errors of the hanging async calls
402
+ return
403
+ }
376
404
  throw getEnhancedHelperError(asyncError)
377
405
  })
378
406
  }
@@ -72,7 +72,15 @@ class Profiler {
72
72
  }
73
73
 
74
74
  if (m.doDiffs !== false && req.context.profiling.mode === 'full' && (m.type === 'operationStart' || m.type === 'operationEnd')) {
75
- let content = res.content
75
+ let originalResContent = res.content
76
+
77
+ // if content is empty assume null to keep old logic working without major changes
78
+ // (here and in studio)
79
+ if (originalResContent != null && originalResContent.length === 0) {
80
+ originalResContent = null
81
+ }
82
+
83
+ let content = originalResContent
76
84
 
77
85
  if (content != null) {
78
86
  if (content.length > this.reporter.options.profiler.maxDiffSize) {
@@ -82,12 +90,12 @@ class Profiler {
82
90
  } else {
83
91
  if (isbinaryfile(content)) {
84
92
  content = {
85
- content: res.content.toString('base64'),
93
+ content: originalResContent.toString('base64'),
86
94
  encoding: 'base64'
87
95
  }
88
96
  } else {
89
97
  content = {
90
- content: createPatch('res', req.context.profiling.resLastVal ? req.context.profiling.resLastVal.toString() : '', res.content.toString(), 0),
98
+ content: createPatch('res', req.context.profiling.resLastVal ? req.context.profiling.resLastVal.toString() : '', originalResContent.toString(), 0),
91
99
  encoding: 'diff'
92
100
  }
93
101
  }
@@ -107,7 +115,7 @@ class Profiler {
107
115
  m.req.diff = createPatch('req', req.context.profiling.reqLastVal || '', stringifiedReq, 0)
108
116
  }
109
117
 
110
- req.context.profiling.resLastVal = (res.content == null || isbinaryfile(res.content) || content.tooLarge) ? null : res.content.toString()
118
+ req.context.profiling.resLastVal = (originalResContent == null || isbinaryfile(originalResContent) || content.tooLarge) ? null : originalResContent.toString()
111
119
  req.context.profiling.resMetaLastVal = stringifiedResMeta
112
120
  req.context.profiling.reqLastVal = stringifiedReq
113
121
  }
@@ -3,11 +3,10 @@
3
3
  *
4
4
  * Orchestration of the rendering process
5
5
  */
6
- const { Readable } = require('stream')
7
6
  const extend = require('node.extend.without.arrays')
8
7
  const ExecuteEngine = require('./executeEngine')
9
8
  const Request = require('../../shared/request')
10
- const generateRequestId = require('../../shared/generateRequestId')
9
+ const Response = require('../../shared/response')
11
10
  const resolveReferences = require('./resolveReferences.js')
12
11
  const moduleHelper = require('./moduleHelper')
13
12
 
@@ -60,8 +59,7 @@ module.exports = (reporter) => {
60
59
  reporter.logger.debug(`Rendering engine ${engine.name}`, request)
61
60
 
62
61
  const engineRes = await executeEngine(engine, request)
63
-
64
- response.content = Buffer.from(engineRes.content != null ? engineRes.content : '')
62
+ await response.output.update(Buffer.from(engineRes.content != null ? engineRes.content : ''))
65
63
 
66
64
  reporter.profiler.emit({
67
65
  type: 'operationEnd',
@@ -93,6 +91,7 @@ module.exports = (reporter) => {
93
91
  reporter.logger.debug('Executing recipe ' + request.template.recipe, request)
94
92
 
95
93
  await recipe.execute(request, response)
94
+
96
95
  reporter.profiler.emit({
97
96
  type: 'operationEnd',
98
97
  operationId: recipeProfilerEvent.operationId
@@ -101,22 +100,24 @@ module.exports = (reporter) => {
101
100
 
102
101
  async function afterRender (reporter, request, response) {
103
102
  await reporter.afterRenderListeners.fire(request, response)
104
-
105
- response.stream = Readable.from(response.content)
106
- response.result = response.stream
107
-
108
103
  return response
109
104
  }
110
105
 
111
106
  return async (req, parentReq) => {
112
107
  const request = Request(req, parentReq)
113
- const response = { meta: {} }
108
+
109
+ if (request.context.id == null) {
110
+ request.context.id = reporter.generateRequestId()
111
+ }
112
+ if (parentReq == null) {
113
+ reporter.reqStorage.registerReq(request)
114
+ }
115
+
116
+ const response = Response(reporter, request.context.id)
117
+
114
118
  let renderStartProfilerEvent
115
- try {
116
- if (request.context.id == null) {
117
- request.context.id = generateRequestId()
118
- }
119
119
 
120
+ try {
120
121
  renderStartProfilerEvent = await reporter.profiler.renderStart(request, parentReq, response)
121
122
  request.data = resolveReferences(request.data) || {}
122
123
 
@@ -194,6 +195,7 @@ module.exports = (reporter) => {
194
195
  } finally {
195
196
  if (parentReq == null) {
196
197
  reporter.requestModulesCache.delete(request.context.rootId)
198
+ reporter.reqStorage.unregisterReq(request)
197
199
  }
198
200
  }
199
201
  }
@@ -10,6 +10,7 @@ const Reporter = require('../shared/reporter')
10
10
  const BlobStorage = require('./blobStorage.js')
11
11
  const Render = require('./render/render')
12
12
  const Profiler = require('./render/profiler.js')
13
+ const engineStream = require('./render/engineStream.js')
13
14
 
14
15
  class WorkerReporter extends Reporter {
15
16
  constructor (workerData, executeMain) {
@@ -79,6 +80,8 @@ class WorkerReporter extends Reporter {
79
80
  execute: htmlRecipe
80
81
  })
81
82
 
83
+ engineStream(this)
84
+
82
85
  await this.initializeListeners.fire()
83
86
 
84
87
  if (!this._lockedDown && this.options.trustUserCode === false) {
@@ -169,8 +172,8 @@ class WorkerReporter extends Reporter {
169
172
  return proxyInstance
170
173
  }
171
174
 
172
- render (req, parentReq) {
173
- return this._render(req, parentReq)
175
+ async render (req, parentReq) {
176
+ return await this._render(req, parentReq)
174
177
  }
175
178
 
176
179
  async executeMainAction (actionName, data, req) {
@@ -221,17 +224,8 @@ class WorkerReporter extends Reporter {
221
224
 
222
225
  _registerRenderAction () {
223
226
  this.registerWorkerAction('render', async (data, req) => {
224
- const res = await this.render(req)
225
-
226
- const sharedBuf = new SharedArrayBuffer(res.content.byteLength)
227
- const buf = Buffer.from(sharedBuf)
228
-
229
- res.content.copy(buf)
230
-
231
- return {
232
- meta: res.meta,
233
- content: buf
234
- }
227
+ const response = await this._render(req)
228
+ return response.serialize()
235
229
  })
236
230
  }
237
231