@jsreport/jsreport-core 3.0.1 → 3.1.2-test.2

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.
Files changed (79) hide show
  1. package/LICENSE +166 -166
  2. package/README.md +298 -284
  3. package/index.js +29 -27
  4. package/lib/main/blobStorage/blobStorage.js +52 -47
  5. package/lib/main/blobStorage/inMemoryProvider.js +27 -27
  6. package/lib/main/blobStorage/mainActions.js +24 -24
  7. package/lib/main/createDefaultLoggerFormat.js +17 -17
  8. package/lib/main/defaults.js +14 -14
  9. package/lib/main/extensions/discover.js +20 -20
  10. package/lib/main/extensions/extensionsManager.js +264 -265
  11. package/lib/main/extensions/fileUtils.js +56 -55
  12. package/lib/main/extensions/findVersion.js +49 -53
  13. package/lib/main/extensions/locationCache.js +103 -97
  14. package/lib/main/extensions/sorter.js +10 -10
  15. package/lib/main/extensions/validateMinimalVersion.js +50 -50
  16. package/lib/main/folders/cascadeFolderRemove.js +25 -25
  17. package/lib/main/folders/getEntitiesInFolder.js +53 -53
  18. package/lib/main/folders/index.js +42 -42
  19. package/lib/main/folders/moveBetweenFolders.js +354 -354
  20. package/lib/main/folders/validateDuplicatedName.js +107 -107
  21. package/lib/main/folders/validateReservedName.js +53 -53
  22. package/lib/main/logger.js +244 -244
  23. package/lib/main/migration/resourcesToAssets.js +230 -210
  24. package/lib/main/migration/xlsxTemplatesToAssets.js +128 -118
  25. package/lib/main/monitoring.js +91 -91
  26. package/lib/main/optionsLoad.js +237 -237
  27. package/lib/main/optionsSchema.js +237 -237
  28. package/lib/main/reporter.js +579 -578
  29. package/lib/main/schemaValidator.js +252 -252
  30. package/lib/main/settings.js +154 -154
  31. package/lib/main/store/checkDuplicatedId.js +27 -27
  32. package/lib/main/store/collection.js +329 -329
  33. package/lib/main/store/documentStore.js +469 -469
  34. package/lib/main/store/mainActions.js +28 -28
  35. package/lib/main/store/memoryStoreProvider.js +99 -99
  36. package/lib/main/store/queue.js +48 -48
  37. package/lib/main/store/referenceUtils.js +251 -251
  38. package/lib/main/store/setupValidateId.js +43 -43
  39. package/lib/main/store/setupValidateShortid.js +71 -71
  40. package/lib/main/store/transaction.js +69 -69
  41. package/lib/main/store/typeUtils.js +180 -180
  42. package/lib/main/templates.js +34 -34
  43. package/lib/main/validateEntityName.js +62 -62
  44. package/lib/shared/createError.js +36 -36
  45. package/lib/shared/encryption.js +114 -114
  46. package/lib/shared/folders/index.js +11 -11
  47. package/lib/shared/folders/normalizeEntityPath.js +15 -15
  48. package/lib/shared/folders/resolveEntityFromPath.js +88 -88
  49. package/lib/shared/folders/resolveEntityPath.js +46 -46
  50. package/lib/shared/folders/resolveFolderFromPath.js +38 -38
  51. package/lib/shared/generateRequestId.js +4 -4
  52. package/lib/shared/listenerCollection.js +169 -0
  53. package/lib/shared/normalizeMetaFromLogs.js +30 -30
  54. package/lib/shared/reporter.js +123 -123
  55. package/lib/shared/request.js +64 -64
  56. package/lib/shared/tempFilesHandler.js +81 -81
  57. package/lib/shared/templates.js +82 -82
  58. package/lib/static/helpers.js +33 -33
  59. package/lib/worker/blobStorage.js +34 -34
  60. package/lib/worker/defaultProxyExtend.js +46 -46
  61. package/lib/worker/documentStore.js +49 -49
  62. package/lib/worker/extensionsManager.js +17 -17
  63. package/lib/worker/logger.js +48 -48
  64. package/lib/worker/render/diff.js +138 -138
  65. package/lib/worker/render/executeEngine.js +207 -200
  66. package/lib/worker/render/htmlRecipe.js +10 -10
  67. package/lib/worker/render/moduleHelper.js +43 -43
  68. package/lib/worker/render/noneEngine.js +12 -12
  69. package/lib/worker/render/profiler.js +158 -158
  70. package/lib/worker/render/render.js +205 -209
  71. package/lib/worker/render/resolveReferences.js +60 -60
  72. package/lib/worker/reporter.js +191 -187
  73. package/lib/worker/sandbox/runInSandbox.js +13 -4
  74. package/lib/worker/sandbox/safeSandbox.js +828 -822
  75. package/lib/worker/templates.js +78 -78
  76. package/lib/worker/workerHandler.js +54 -54
  77. package/package.json +92 -92
  78. package/test/blobStorage/common.js +21 -21
  79. package/test/store/common.js +1449 -1449
@@ -1,578 +1,579 @@
1
- /*!
2
- * Copyright(c) 2018 Jan Blaha
3
- *
4
- * Reporter main class including all methods jsreport-core exposes.
5
- */
6
- const path = require('path')
7
- const { Readable } = require('stream')
8
- const Reaper = require('reap2')
9
- const optionsLoad = require('./optionsLoad')
10
- const { createLogger, configureLogger, silentLogs } = require('./logger')
11
- const checkEntityName = require('./validateEntityName')
12
- const DocumentStore = require('./store/documentStore')
13
- const BlobStorage = require('./blobStorage/blobStorage')
14
- const ExtensionsManager = require('./extensions/extensionsManager')
15
- const Settings = require('./settings')
16
- const SchemaValidator = require('./schemaValidator')
17
- const { getRootSchemaOptions, extendRootSchemaOptions } = require('./optionsSchema')
18
- const Templates = require('./templates')
19
- const Folders = require('./folders')
20
- const WorkersManager = require('@jsreport/advanced-workers')
21
- const { validateDuplicatedName } = require('./folders/validateDuplicatedName')
22
- const { validateReservedName } = require('./folders/validateReservedName')
23
- const setupValidateId = require('./store/setupValidateId')
24
- const setupValidateShortid = require('./store/setupValidateShortid')
25
- const documentStoreActions = require('./store/mainActions')
26
- const blobStorageActions = require('./blobStorage/mainActions')
27
- const Reporter = require('../shared/reporter')
28
- const Request = require('../shared/request')
29
- const generateRequestId = require('../shared/generateRequestId')
30
- const Profiler = require('./profiler')
31
- const Monitoring = require('./monitoring')
32
- const migrateXlsxTemplatesToAssets = require('./migration/xlsxTemplatesToAssets')
33
- const migrateResourcesToAssets = require('./migration/resourcesToAssets')
34
- const semver = require('semver')
35
-
36
- class MainReporter extends Reporter {
37
- constructor (options, defaults) {
38
- super(options)
39
-
40
- if (!semver.satisfies(process.versions.node, '>=16.11.0')) {
41
- throw this.createError('jsreport needs at least node 16.11.0 to run.')
42
- }
43
-
44
- this.defaults = defaults || {}
45
- this._fnAfterConfigLoaded = () => {}
46
- this._reaperTimerRef = null
47
- this._extraPathsToCleanupCollection = new Set()
48
-
49
- this._initialized = false
50
- this._initializing = false
51
-
52
- this._initExecution = {}
53
-
54
- this._initExecution.promise = new Promise((resolve, reject) => {
55
- this._initExecution.resolve = resolve
56
- this._initExecution.reject = resolve
57
- })
58
-
59
- this._mainActions = new Map()
60
-
61
- this.settings = new Settings()
62
- this.extensionsManager = ExtensionsManager(this)
63
-
64
- this.optionsValidator = new SchemaValidator({
65
- rootSchema: getRootSchemaOptions()
66
- })
67
-
68
- this.entityTypeValidator = new SchemaValidator()
69
-
70
- this.logger = createLogger()
71
- this.beforeMainActionListeners = this.createListenerCollection()
72
- }
73
-
74
- discover () {
75
- this.options.discover = true
76
- return this
77
- }
78
-
79
- /**
80
- * Manual registration of the extension. Once calling `use` the auto discovery of extensions is turned off if not explicitly
81
- * turned on.
82
- * jsreport.use(require('@jsreport/jsreport-jsrender')())
83
- * @param {Object || Function} extensions
84
- * @return {Reporter} for chaining
85
- * @public
86
- */
87
- use (extension) {
88
- this.extensionsManager.use(extension)
89
- return this
90
- }
91
-
92
- async extensionsLoad (opts) {
93
- const appliedConfigFile = await optionsLoad({
94
- defaults: this.defaults,
95
- options: this.options,
96
- validator: this.optionsValidator,
97
- onConfigLoaded: async () => {
98
- await this._fnAfterConfigLoaded(this)
99
- }
100
- })
101
-
102
- configureLogger(this.logger, this.options.logger)
103
-
104
- if (this.options.logger && this.options.logger.silent === true) {
105
- silentLogs(this.logger)
106
- }
107
-
108
- this.logger.info(`Initializing jsreport (version: ${this.version}, configuration file: ${appliedConfigFile || 'none'}, nodejs: ${process.versions.node})`)
109
-
110
- await this.extensionsManager.load(opts)
111
-
112
- const newRootSchema = extendRootSchemaOptions(
113
- getRootSchemaOptions(),
114
- this.extensionsManager.availableExtensions.map(ex => ({
115
- name: ex.name,
116
- schema: ex.optionsSchema
117
- }))
118
- )
119
-
120
- this.optionsValidator.setRootSchema(newRootSchema)
121
-
122
- const rootOptionsValidation = this.optionsValidator.validateRoot(this.options, {
123
- rootPrefix: 'rootOptions',
124
- // extensions was validated already in extensions load
125
- ignore: ['properties.extensions']
126
- })
127
-
128
- if (!rootOptionsValidation.valid) {
129
- throw new Error(`options contain values that does not match the defined full root schema. ${rootOptionsValidation.fullErrorMessage}`)
130
- }
131
-
132
- return this
133
- }
134
-
135
- /**
136
- * Hook to alter configuration after it was loaded and merged
137
- * jsreport.afterConfigLoaded(function(reporter) { .. do your stuff ..})
138
- *
139
- *
140
- * @public
141
- */
142
- afterConfigLoaded (fn) {
143
- this._fnAfterConfigLoaded = fn
144
- return this
145
- }
146
-
147
- async waitForInit () {
148
- await this._initExecution.promise
149
- }
150
-
151
- /**
152
- * Required async method to be called before rendering.
153
- *
154
- * @return {Promise} initialization is done, promise value is Reporter instance for chaining
155
- * @public
156
- */
157
- async init () {
158
- this.closing = this.closed = false
159
- if (this._initialized || this._initializing) {
160
- throw new Error('jsreport already initialized or just initializing. Make sure init is called only once')
161
- }
162
-
163
- super.init()
164
-
165
- this._initializing = true
166
-
167
- if (this.compilation) {
168
- this.compilation.resource('sandbox.js', require.resolve('vm2/lib/sandbox.js'))
169
- this.compilation.resource('contextify.js', require.resolve('vm2/lib/contextify.js'))
170
- }
171
-
172
- try {
173
- this._registerLogMainAction()
174
- await this.extensionsLoad()
175
-
176
- this.documentStore = DocumentStore(Object.assign({}, this.options, { logger: this.logger }), this.entityTypeValidator, this.encryption)
177
- documentStoreActions(this)
178
- this.blobStorage = BlobStorage(this, this.options)
179
- blobStorageActions(this)
180
- Templates(this)
181
- Profiler(this)
182
- Monitoring(this)
183
-
184
- this.folders = Object.assign(this.folders, Folders(this))
185
-
186
- this.settings.registerEntity(this.documentStore)
187
-
188
- this.options.blobStorage = this.options.blobStorage || {}
189
-
190
- if (this.options.blobStorage.provider == null) {
191
- this.options.blobStorage.provider = this.options.store.provider
192
- }
193
-
194
- if (this.options.blobStorage.provider === 'memory') {
195
- this.blobStorage.registerProvider(require('./blobStorage/inMemoryProvider.js')(this.options))
196
- }
197
-
198
- await this.extensionsManager.init()
199
-
200
- this.logger.info(`Using general timeout for rendering (reportTimeout: ${this.options.reportTimeout})`)
201
-
202
- if (this.options.store.provider === 'memory') {
203
- this.logger.info(`Using ${this.options.store.provider} provider for template store. The saved templates will be lost after restart`)
204
- } else {
205
- this.logger.info(`Using ${this.options.store.provider} provider for template store.`)
206
- }
207
-
208
- await this.documentStore.init()
209
- await this.blobStorage.init()
210
- await this.settings.init(this.documentStore, this.authorization)
211
-
212
- const extensionsForWorkers = this.extensionsManager.extensions.filter(e => e.worker)
213
-
214
- const workersManagerOptions = {
215
- ...this.options.sandbox,
216
- options: { ...this.options },
217
- // we do map and copy to unproxy the value
218
- extensionsDefs: extensionsForWorkers.map(e => Object.assign({}, e)),
219
- documentStore: {
220
- // we do copy to unproxy the value of entityTypes
221
- model: {
222
- ...this.documentStore.model,
223
- entityTypes: { ...this.documentStore.model.entityTypes }
224
- },
225
- collections: Object.keys(this.documentStore.collections)
226
- }
227
- }
228
-
229
- const workersManagerSystemOptions = {
230
- initTimeout: this.options.workers.initTimeout,
231
- numberOfWorkers: this.options.workers.numberOfWorkers,
232
- workerModule: path.join(__dirname, '../worker', 'workerHandler.js'),
233
- resourceLimits: this.options.workers.resourceLimits
234
- }
235
-
236
- // adding the validation of shortid after extensions has been loaded
237
- setupValidateId(this)
238
- setupValidateShortid(this)
239
-
240
- this.initializeListeners.insert(0, 'core-resources-migration', () => migrateResourcesToAssets(this))
241
- this.initializeListeners.insert(0, 'core-xlsxTemplates-migration', () => migrateXlsxTemplatesToAssets(this))
242
-
243
- await this.initializeListeners.fire()
244
-
245
- this._workersManager = this._workersManagerFactory
246
- ? this._workersManagerFactory(workersManagerOptions, workersManagerSystemOptions)
247
- : WorkersManager(workersManagerOptions, workersManagerSystemOptions, this.logger)
248
-
249
- const workersStart = new Date().getTime()
250
-
251
- this.logger.info('Initializing worker threads')
252
-
253
- this.logger.debug(`Extensions in workers: ${extensionsForWorkers.map((e) => e.name).join(', ')}`)
254
-
255
- await this._workersManager.init(workersManagerOptions)
256
-
257
- this.logger.info(`${this.options.workers.numberOfWorkers} worker threads initialized in ${new Date().getTime() - workersStart}ms`)
258
-
259
- this._startReaper(this.getPathsToWatchForAutoCleanup())
260
-
261
- this.extensionsManager.recipes.push({
262
- name: 'html'
263
- })
264
-
265
- this.extensionsManager.engines.push({
266
- name: 'none'
267
- })
268
-
269
- this.monitoring.init()
270
-
271
- this.logger.info('reporter initialized')
272
- this._initialized = true
273
- this._initExecution.resolve()
274
- return this
275
- } catch (e) {
276
- this.logger.error(`Error occurred during reporter init: ${e.stack}`)
277
- this._initExecution.reject(new Error(`Reporter initialization failed. ${e.message}`))
278
- throw e
279
- }
280
- }
281
-
282
- /**
283
- * @public
284
- */
285
- addPathToWatchForAutoCleanup (customPath) {
286
- this._extraPathsToCleanupCollection.add(customPath)
287
- }
288
-
289
- /**
290
- * @public
291
- */
292
- getPathsToWatchForAutoCleanup () {
293
- return [this.options.tempAutoCleanupDirectory].concat(Array.from(this._extraPathsToCleanupCollection.values()))
294
- }
295
-
296
- async checkValidEntityName (c, doc, req) {
297
- if (!this.documentStore.model.entitySets[c].entityTypeDef.name) {
298
- return
299
- }
300
-
301
- checkEntityName(doc.name)
302
-
303
- await validateDuplicatedName(this, c, doc, undefined, req)
304
-
305
- await validateReservedName(this, c, doc)
306
- }
307
-
308
- /**
309
- * Main method for invoking rendering
310
- * render({ template: { content: 'foo', engine: 'none', recipe: 'html' }, data: { foo: 'hello' } })
311
- *
312
- * @request {Object}
313
- * @return {Promise} response.content is output buffer, response.stream is output stream, response.headers contains http applicable headers
314
- *
315
- * @public
316
- */
317
- async render (req, parentReq) {
318
- if (!this._initialized) {
319
- throw new Error('Not initialized, you need to call jsreport.init().then before rendering')
320
- }
321
-
322
- req = Object.assign({}, req)
323
- req.context = Object.assign({}, req.context)
324
- req.context.rootId = req.context.rootId || generateRequestId()
325
- req.context.id = req.context.rootId
326
-
327
- const worker = await this._workersManager.allocate(req, {
328
- timeout: this.options.reportTimeout
329
- })
330
-
331
- let workerAborted
332
- if (parentReq && !parentReq.__isJsreportRequest__) {
333
- const options = parentReq
334
- parentReq = null
335
-
336
- if (options.abortEmitter) {
337
- options.abortEmitter.once('abort', () => {
338
- workerAborted = true
339
- worker.release(req).catch((e) => this.logger.error('Failed to release worker ' + e))
340
- })
341
- }
342
- }
343
-
344
- const res = { meta: {} }
345
- try {
346
- if (workerAborted) {
347
- throw this.createError('Request aborted by client')
348
- }
349
-
350
- let isDataStoredInWorker = false
351
-
352
- if (req.rawContent) {
353
- isDataStoredInWorker = true
354
- const result = await worker.execute({
355
- actionName: 'parse',
356
- req,
357
- data: {}
358
- }, {
359
- timeout: this.options.reportTimeout
360
- })
361
- req = result
362
- }
363
-
364
- req = Request(req, parentReq)
365
-
366
- if (isDataStoredInWorker) {
367
- // we unset this because we want the Request() call in worker to evaluate the data
368
- // and determine if the original was empty or not
369
- delete req.context.originalInputDataIsEmpty
370
- }
371
-
372
- // TODO: we will probably validate in the thread
373
- if (this.entityTypeValidator.getSchema('TemplateType') != null) {
374
- const templateValidationResult = this.entityTypeValidator.validate('TemplateType', req.template, { rootPrefix: 'template' })
375
-
376
- if (!templateValidationResult.valid) {
377
- throw this.createError(`template input in request contain values that does not match the defined schema. ${templateValidationResult.fullErrorMessage}`, {
378
- statusCode: 400
379
- })
380
- }
381
- }
382
-
383
- let reportTimeout = this.options.reportTimeout
384
-
385
- if (
386
- this.options.enableRequestReportTimeout &&
387
- req.options &&
388
- req.options.timeout != null
389
- ) {
390
- reportTimeout = req
391
- }
392
-
393
- await this.beforeRenderListeners.fire(req, res)
394
-
395
- if (req.context.isFinished) {
396
- res.stream = Readable.from(res.content)
397
- return res
398
- }
399
-
400
- if (workerAborted) {
401
- throw this.createError('Request aborted by client')
402
- }
403
-
404
- const responseResult = await this.executeWorkerAction('render', {}, {
405
- timeout: reportTimeout + this.options.reportTimeoutMargin,
406
- worker
407
- }, req)
408
-
409
- Object.assign(res, responseResult)
410
- await this.afterRenderListeners.fire(req, res)
411
- res.stream = Readable.from(res.content)
412
- return res
413
- } catch (err) {
414
- if (err.code === 'WORKER_TIMEOUT') {
415
- err.message = 'Report timeout'
416
- err.weak = true
417
- }
418
-
419
- if (err.code === 'WORKER_ABORTED') {
420
- err.message = 'Report cancelled'
421
- err.weak = true
422
- }
423
-
424
- if (!err.logged) {
425
- const logFn = err.weak ? this.logger.warn : this.logger.error
426
- logFn(`Report render failed: ${err.message}${err.stack != null ? ' ' + err.stack : ''}`, req)
427
- }
428
- await this.renderErrorListeners.fire(req, res, err)
429
- throw err
430
- } finally {
431
- if (!workerAborted) {
432
- await worker.release(req)
433
- }
434
- }
435
- }
436
-
437
- generateRequestId () {
438
- return generateRequestId()
439
- }
440
-
441
- registerWorkersManagerFactory (workersManagerFactory) {
442
- this._workersManagerFactory = workersManagerFactory
443
- }
444
-
445
- /**
446
- *
447
- * @public
448
- */
449
- async close () {
450
- this.closing = true
451
- this.logger.info('Closing jsreport instance')
452
-
453
- if (this.monitoring) {
454
- await this.monitoring.close()
455
- }
456
-
457
- if (this._reaperTimerRef) {
458
- clearInterval(this._reaperTimerRef)
459
- }
460
-
461
- if (this._workersManager) {
462
- await this._workersManager.close()
463
- }
464
-
465
- await this.closeListeners.fire()
466
-
467
- if (this.documentStore) {
468
- await this.documentStore.close()
469
- }
470
-
471
- this.logger.info('jsreport instance has been closed')
472
- this.closed = true
473
-
474
- return this
475
- }
476
-
477
- registerMainAction (actionName, fn) {
478
- this._mainActions.set(actionName, fn)
479
- }
480
-
481
- async _invokeMainAction (data, req) {
482
- await this.beforeMainActionListeners.fire(data.actionName, data.data, req)
483
- if (!this._mainActions.has(data.actionName)) {
484
- throw this.createError(`Main process action ${data.actionName} wasn't registered`)
485
- }
486
- return this._mainActions.get(data.actionName)(data.data, req)
487
- }
488
-
489
- _registerLogMainAction () {
490
- this.registerMainAction('log', (log, req) => {
491
- this.logger[log.level](log.message, { ...req, ...log.meta, timestamp: log.timestamp })
492
- })
493
- }
494
-
495
- async executeWorkerAction (actionName, data, options = {}, req) {
496
- req.context.rootId = req.context.rootId || generateRequestId()
497
- const timeout = options.timeout || 60000
498
-
499
- const worker = options.worker
500
- ? options.worker
501
- : await this._workersManager.allocate(req, {
502
- timeout
503
- })
504
-
505
- try {
506
- const result = await worker.execute({
507
- actionName,
508
- data,
509
- // we set just known props, to avoid cloning failures on express req properties
510
- req: {
511
- context: req.context,
512
- template: req.template,
513
- data: req.data,
514
- options: req.options
515
- }
516
- }, {
517
- // TODO add worker timeout
518
- timeout,
519
- timeoutErrorMessage: options.timeoutErrorMessage || ('Timeout during worker action ' + actionName),
520
- executeMain: async (data) => {
521
- return this._invokeMainAction(data, req)
522
- }
523
- })
524
- this._workersManager.convertUint8ArrayToBuffer(result)
525
- return result
526
- } finally {
527
- if (!options.worker) {
528
- await worker.release(req)
529
- }
530
- }
531
- }
532
-
533
- /**
534
- * Periodical cleaning of folders where recipes are storing files like source html for pdf rendering
535
- *
536
- * @private
537
- */
538
- _startReaper (dir) {
539
- const dirsToWatch = !Array.isArray(dir) ? [dir] : dir
540
-
541
- if (this.options.autoTempCleanup === false) {
542
- return
543
- }
544
-
545
- const threshold = this.options.reportTimeout > 180000 ? this.options.reportTimeout : 180000
546
-
547
- this.logger.info(`Starting temp files cleanup with ${threshold}ms threshold`)
548
-
549
- const reaper = new Reaper({ threshold })
550
-
551
- dirsToWatch.forEach(d => reaper.watch(d))
552
-
553
- reaper.start((err, files) => {
554
- if (err) {
555
- this.logger.error(`Failed to start auto cleanup: ${err.stack}`)
556
- }
557
- })
558
-
559
- this._reaperTimerRef = setInterval(() => {
560
- try {
561
- reaper.start((err, files) => {
562
- if (err) {
563
- // NOT logging the error anymore because it was confusing users that something bad was happening
564
- // this.logger.error('Failed to delete temp file: ' + err)
565
- }
566
- })
567
- } catch (e) {
568
- // NOT logging the error anymore because it was confusing users that something bad was happening
569
- // catch error in case the reaper can not read directory
570
- // this.logger.error('Failed to run reaper: ' + e)
571
- }
572
- }, 30000 /* check every 30s for old files */)
573
-
574
- this._reaperTimerRef.unref()
575
- }
576
- }
577
-
578
- module.exports = MainReporter
1
+ /*!
2
+ * Copyright(c) 2018 Jan Blaha
3
+ *
4
+ * Reporter main class including all methods jsreport-core exposes.
5
+ */
6
+ const path = require('path')
7
+ const { Readable } = require('stream')
8
+ const Reaper = require('reap2')
9
+ const optionsLoad = require('./optionsLoad')
10
+ const { createLogger, configureLogger, silentLogs } = require('./logger')
11
+ const checkEntityName = require('./validateEntityName')
12
+ const DocumentStore = require('./store/documentStore')
13
+ const BlobStorage = require('./blobStorage/blobStorage')
14
+ const ExtensionsManager = require('./extensions/extensionsManager')
15
+ const Settings = require('./settings')
16
+ const SchemaValidator = require('./schemaValidator')
17
+ const { getRootSchemaOptions, extendRootSchemaOptions } = require('./optionsSchema')
18
+ const Templates = require('./templates')
19
+ const Folders = require('./folders')
20
+ const WorkersManager = require('@jsreport/advanced-workers')
21
+ const { validateDuplicatedName } = require('./folders/validateDuplicatedName')
22
+ const { validateReservedName } = require('./folders/validateReservedName')
23
+ const setupValidateId = require('./store/setupValidateId')
24
+ const setupValidateShortid = require('./store/setupValidateShortid')
25
+ const documentStoreActions = require('./store/mainActions')
26
+ const blobStorageActions = require('./blobStorage/mainActions')
27
+ const Reporter = require('../shared/reporter')
28
+ const Request = require('../shared/request')
29
+ const generateRequestId = require('../shared/generateRequestId')
30
+ const Profiler = require('./profiler')
31
+ const Monitoring = require('./monitoring')
32
+ const migrateXlsxTemplatesToAssets = require('./migration/xlsxTemplatesToAssets')
33
+ const migrateResourcesToAssets = require('./migration/resourcesToAssets')
34
+ const semver = require('semver')
35
+
36
+ class MainReporter extends Reporter {
37
+ constructor (options, defaults) {
38
+ super(options)
39
+
40
+ if (!semver.satisfies(process.versions.node, '>=16.11.0')) {
41
+ throw this.createError('jsreport needs at least node 16.11.0 to run.')
42
+ }
43
+
44
+ this.defaults = defaults || {}
45
+ this._fnAfterConfigLoaded = () => {}
46
+ this._reaperTimerRef = null
47
+ this._extraPathsToCleanupCollection = new Set()
48
+
49
+ this._initialized = false
50
+ this._initializing = false
51
+
52
+ this._initExecution = {}
53
+
54
+ this._initExecution.promise = new Promise((resolve, reject) => {
55
+ this._initExecution.resolve = resolve
56
+ this._initExecution.reject = resolve
57
+ })
58
+
59
+ this._mainActions = new Map()
60
+
61
+ this.settings = new Settings()
62
+ this.extensionsManager = ExtensionsManager(this)
63
+
64
+ this.optionsValidator = new SchemaValidator({
65
+ rootSchema: getRootSchemaOptions()
66
+ })
67
+
68
+ this.entityTypeValidator = new SchemaValidator()
69
+
70
+ this.logger = createLogger()
71
+ this.beforeMainActionListeners = this.createListenerCollection('beforeMainAction')
72
+ }
73
+
74
+ discover () {
75
+ this.options.discover = true
76
+ return this
77
+ }
78
+
79
+ /**
80
+ * Manual registration of the extension. Once calling `use` the auto discovery of extensions is turned off if not explicitly
81
+ * turned on.
82
+ * jsreport.use(require('@jsreport/jsreport-jsrender')())
83
+ * @param {Object || Function} extensions
84
+ * @return {Reporter} for chaining
85
+ * @public
86
+ */
87
+ use (extension) {
88
+ this.extensionsManager.use(extension)
89
+ return this
90
+ }
91
+
92
+ async extensionsLoad (opts) {
93
+ const appliedConfigFile = await optionsLoad({
94
+ defaults: this.defaults,
95
+ options: this.options,
96
+ validator: this.optionsValidator,
97
+ onConfigLoaded: async () => {
98
+ await this._fnAfterConfigLoaded(this)
99
+ }
100
+ })
101
+
102
+ configureLogger(this.logger, this.options.logger)
103
+
104
+ if (this.options.logger && this.options.logger.silent === true) {
105
+ silentLogs(this.logger)
106
+ }
107
+
108
+ this.logger.info(`Initializing jsreport (version: ${this.version}, configuration file: ${appliedConfigFile || 'none'}, nodejs: ${process.versions.node})`)
109
+
110
+ await this.extensionsManager.load(opts)
111
+
112
+ const newRootSchema = extendRootSchemaOptions(
113
+ getRootSchemaOptions(),
114
+ this.extensionsManager.availableExtensions.map(ex => ({
115
+ name: ex.name,
116
+ schema: ex.optionsSchema
117
+ }))
118
+ )
119
+
120
+ this.optionsValidator.setRootSchema(newRootSchema)
121
+
122
+ const rootOptionsValidation = this.optionsValidator.validateRoot(this.options, {
123
+ rootPrefix: 'rootOptions',
124
+ // extensions was validated already in extensions load
125
+ ignore: ['properties.extensions']
126
+ })
127
+
128
+ if (!rootOptionsValidation.valid) {
129
+ throw new Error(`options contain values that does not match the defined full root schema. ${rootOptionsValidation.fullErrorMessage}`)
130
+ }
131
+
132
+ return this
133
+ }
134
+
135
+ /**
136
+ * Hook to alter configuration after it was loaded and merged
137
+ * jsreport.afterConfigLoaded(function(reporter) { .. do your stuff ..})
138
+ *
139
+ *
140
+ * @public
141
+ */
142
+ afterConfigLoaded (fn) {
143
+ this._fnAfterConfigLoaded = fn
144
+ return this
145
+ }
146
+
147
+ async waitForInit () {
148
+ await this._initExecution.promise
149
+ }
150
+
151
+ /**
152
+ * Required async method to be called before rendering.
153
+ *
154
+ * @return {Promise} initialization is done, promise value is Reporter instance for chaining
155
+ * @public
156
+ */
157
+ async init () {
158
+ this.closing = this.closed = false
159
+ if (this._initialized || this._initializing) {
160
+ throw new Error('jsreport already initialized or just initializing. Make sure init is called only once')
161
+ }
162
+
163
+ super.init()
164
+
165
+ this._initializing = true
166
+
167
+ if (this.compilation) {
168
+ this.compilation.resource('sandbox.js', require.resolve('vm2/lib/sandbox.js'))
169
+ this.compilation.resource('contextify.js', require.resolve('vm2/lib/contextify.js'))
170
+ this.compilation.resource('fixasync.js', require.resolve('vm2/lib/fixasync.js'))
171
+ }
172
+
173
+ try {
174
+ this._registerLogMainAction()
175
+ await this.extensionsLoad()
176
+
177
+ this.documentStore = DocumentStore(Object.assign({}, this.options, { logger: this.logger }), this.entityTypeValidator, this.encryption)
178
+ documentStoreActions(this)
179
+ this.blobStorage = BlobStorage(this, this.options)
180
+ blobStorageActions(this)
181
+ Templates(this)
182
+ Profiler(this)
183
+ Monitoring(this)
184
+
185
+ this.folders = Object.assign(this.folders, Folders(this))
186
+
187
+ this.settings.registerEntity(this.documentStore)
188
+
189
+ this.options.blobStorage = this.options.blobStorage || {}
190
+
191
+ if (this.options.blobStorage.provider == null) {
192
+ this.options.blobStorage.provider = this.options.store.provider
193
+ }
194
+
195
+ if (this.options.blobStorage.provider === 'memory') {
196
+ this.blobStorage.registerProvider(require('./blobStorage/inMemoryProvider.js')(this.options))
197
+ }
198
+
199
+ await this.extensionsManager.init()
200
+
201
+ this.logger.info(`Using general timeout for rendering (reportTimeout: ${this.options.reportTimeout})`)
202
+
203
+ if (this.options.store.provider === 'memory') {
204
+ this.logger.info(`Using ${this.options.store.provider} provider for template store. The saved templates will be lost after restart`)
205
+ } else {
206
+ this.logger.info(`Using ${this.options.store.provider} provider for template store.`)
207
+ }
208
+
209
+ await this.documentStore.init()
210
+ await this.blobStorage.init()
211
+ await this.settings.init(this.documentStore, this.authorization)
212
+
213
+ const extensionsForWorkers = this.extensionsManager.extensions.filter(e => e.worker)
214
+
215
+ const workersManagerOptions = {
216
+ ...this.options.sandbox,
217
+ options: { ...this.options },
218
+ // we do map and copy to unproxy the value
219
+ extensionsDefs: extensionsForWorkers.map(e => Object.assign({}, e)),
220
+ documentStore: {
221
+ // we do copy to unproxy the value of entityTypes
222
+ model: {
223
+ ...this.documentStore.model,
224
+ entityTypes: { ...this.documentStore.model.entityTypes }
225
+ },
226
+ collections: Object.keys(this.documentStore.collections)
227
+ }
228
+ }
229
+
230
+ const workersManagerSystemOptions = {
231
+ initTimeout: this.options.workers.initTimeout,
232
+ numberOfWorkers: this.options.workers.numberOfWorkers,
233
+ workerModule: path.join(__dirname, '../worker', 'workerHandler.js'),
234
+ resourceLimits: this.options.workers.resourceLimits
235
+ }
236
+
237
+ // adding the validation of shortid after extensions has been loaded
238
+ setupValidateId(this)
239
+ setupValidateShortid(this)
240
+
241
+ this.initializeListeners.insert(0, 'core-resources-migration', () => migrateResourcesToAssets(this))
242
+ this.initializeListeners.insert(0, 'core-xlsxTemplates-migration', () => migrateXlsxTemplatesToAssets(this))
243
+
244
+ await this.initializeListeners.fire()
245
+
246
+ this._workersManager = this._workersManagerFactory
247
+ ? this._workersManagerFactory(workersManagerOptions, workersManagerSystemOptions)
248
+ : WorkersManager(workersManagerOptions, workersManagerSystemOptions, this.logger)
249
+
250
+ const workersStart = new Date().getTime()
251
+
252
+ this.logger.info('Initializing worker threads')
253
+
254
+ this.logger.debug(`Extensions in workers: ${extensionsForWorkers.map((e) => e.name).join(', ')}`)
255
+
256
+ await this._workersManager.init(workersManagerOptions)
257
+
258
+ this.logger.info(`${this.options.workers.numberOfWorkers} worker threads initialized in ${new Date().getTime() - workersStart}ms`)
259
+
260
+ this._startReaper(this.getPathsToWatchForAutoCleanup())
261
+
262
+ this.extensionsManager.recipes.push({
263
+ name: 'html'
264
+ })
265
+
266
+ this.extensionsManager.engines.push({
267
+ name: 'none'
268
+ })
269
+
270
+ this.monitoring.init()
271
+
272
+ this.logger.info('reporter initialized')
273
+ this._initialized = true
274
+ this._initExecution.resolve()
275
+ return this
276
+ } catch (e) {
277
+ this.logger.error(`Error occurred during reporter init: ${e.stack}`)
278
+ this._initExecution.reject(new Error(`Reporter initialization failed. ${e.message}`))
279
+ throw e
280
+ }
281
+ }
282
+
283
+ /**
284
+ * @public
285
+ */
286
+ addPathToWatchForAutoCleanup (customPath) {
287
+ this._extraPathsToCleanupCollection.add(customPath)
288
+ }
289
+
290
+ /**
291
+ * @public
292
+ */
293
+ getPathsToWatchForAutoCleanup () {
294
+ return [this.options.tempAutoCleanupDirectory].concat(Array.from(this._extraPathsToCleanupCollection.values()))
295
+ }
296
+
297
+ async checkValidEntityName (c, doc, req) {
298
+ if (!this.documentStore.model.entitySets[c].entityTypeDef.name) {
299
+ return
300
+ }
301
+
302
+ checkEntityName(doc.name)
303
+
304
+ await validateDuplicatedName(this, c, doc, undefined, req)
305
+
306
+ await validateReservedName(this, c, doc)
307
+ }
308
+
309
+ /**
310
+ * Main method for invoking rendering
311
+ * render({ template: { content: 'foo', engine: 'none', recipe: 'html' }, data: { foo: 'hello' } })
312
+ *
313
+ * @request {Object}
314
+ * @return {Promise} response.content is output buffer, response.stream is output stream, response.headers contains http applicable headers
315
+ *
316
+ * @public
317
+ */
318
+ async render (req, parentReq) {
319
+ if (!this._initialized) {
320
+ throw new Error('Not initialized, you need to call jsreport.init().then before rendering')
321
+ }
322
+
323
+ req = Object.assign({}, req)
324
+ req.context = Object.assign({}, req.context)
325
+ req.context.rootId = req.context.rootId || generateRequestId()
326
+ req.context.id = req.context.rootId
327
+
328
+ const worker = await this._workersManager.allocate(req, {
329
+ timeout: this.options.reportTimeout
330
+ })
331
+
332
+ let workerAborted
333
+ if (parentReq && !parentReq.__isJsreportRequest__) {
334
+ const options = parentReq
335
+ parentReq = null
336
+
337
+ if (options.abortEmitter) {
338
+ options.abortEmitter.once('abort', () => {
339
+ workerAborted = true
340
+ worker.release(req).catch((e) => this.logger.error('Failed to release worker ' + e))
341
+ })
342
+ }
343
+ }
344
+
345
+ const res = { meta: {} }
346
+ try {
347
+ if (workerAborted) {
348
+ throw this.createError('Request aborted by client')
349
+ }
350
+
351
+ let isDataStoredInWorker = false
352
+
353
+ if (req.rawContent) {
354
+ isDataStoredInWorker = true
355
+ const result = await worker.execute({
356
+ actionName: 'parse',
357
+ req,
358
+ data: {}
359
+ }, {
360
+ timeout: this.options.reportTimeout
361
+ })
362
+ req = result
363
+ }
364
+
365
+ req = Request(req, parentReq)
366
+
367
+ if (isDataStoredInWorker) {
368
+ // we unset this because we want the Request() call in worker to evaluate the data
369
+ // and determine if the original was empty or not
370
+ delete req.context.originalInputDataIsEmpty
371
+ }
372
+
373
+ // TODO: we will probably validate in the thread
374
+ if (this.entityTypeValidator.getSchema('TemplateType') != null) {
375
+ const templateValidationResult = this.entityTypeValidator.validate('TemplateType', req.template, { rootPrefix: 'template' })
376
+
377
+ if (!templateValidationResult.valid) {
378
+ throw this.createError(`template input in request contain values that does not match the defined schema. ${templateValidationResult.fullErrorMessage}`, {
379
+ statusCode: 400
380
+ })
381
+ }
382
+ }
383
+
384
+ let reportTimeout = this.options.reportTimeout
385
+
386
+ if (
387
+ this.options.enableRequestReportTimeout &&
388
+ req.options &&
389
+ req.options.timeout != null
390
+ ) {
391
+ reportTimeout = req
392
+ }
393
+
394
+ await this.beforeRenderListeners.fire(req, res)
395
+
396
+ if (req.context.isFinished) {
397
+ res.stream = Readable.from(res.content)
398
+ return res
399
+ }
400
+
401
+ if (workerAborted) {
402
+ throw this.createError('Request aborted by client')
403
+ }
404
+
405
+ const responseResult = await this.executeWorkerAction('render', {}, {
406
+ timeout: reportTimeout + this.options.reportTimeoutMargin,
407
+ worker
408
+ }, req)
409
+
410
+ Object.assign(res, responseResult)
411
+ await this.afterRenderListeners.fire(req, res)
412
+ res.stream = Readable.from(res.content)
413
+ return res
414
+ } catch (err) {
415
+ if (err.code === 'WORKER_TIMEOUT') {
416
+ err.message = 'Report timeout'
417
+ err.weak = true
418
+ }
419
+
420
+ if (err.code === 'WORKER_ABORTED') {
421
+ err.message = 'Report cancelled'
422
+ err.weak = true
423
+ }
424
+
425
+ if (!err.logged) {
426
+ const logFn = err.weak ? this.logger.warn : this.logger.error
427
+ logFn(`Report render failed: ${err.message}${err.stack != null ? ' ' + err.stack : ''}`, req)
428
+ }
429
+ await this.renderErrorListeners.fire(req, res, err)
430
+ throw err
431
+ } finally {
432
+ if (!workerAborted) {
433
+ await worker.release(req)
434
+ }
435
+ }
436
+ }
437
+
438
+ generateRequestId () {
439
+ return generateRequestId()
440
+ }
441
+
442
+ registerWorkersManagerFactory (workersManagerFactory) {
443
+ this._workersManagerFactory = workersManagerFactory
444
+ }
445
+
446
+ /**
447
+ *
448
+ * @public
449
+ */
450
+ async close () {
451
+ this.closing = true
452
+ this.logger.info('Closing jsreport instance')
453
+
454
+ if (this.monitoring) {
455
+ await this.monitoring.close()
456
+ }
457
+
458
+ if (this._reaperTimerRef) {
459
+ clearInterval(this._reaperTimerRef)
460
+ }
461
+
462
+ if (this._workersManager) {
463
+ await this._workersManager.close()
464
+ }
465
+
466
+ await this.closeListeners.fire()
467
+
468
+ if (this.documentStore) {
469
+ await this.documentStore.close()
470
+ }
471
+
472
+ this.logger.info('jsreport instance has been closed')
473
+ this.closed = true
474
+
475
+ return this
476
+ }
477
+
478
+ registerMainAction (actionName, fn) {
479
+ this._mainActions.set(actionName, fn)
480
+ }
481
+
482
+ async _invokeMainAction (data, req) {
483
+ await this.beforeMainActionListeners.fire(data.actionName, data.data, req)
484
+ if (!this._mainActions.has(data.actionName)) {
485
+ throw this.createError(`Main process action ${data.actionName} wasn't registered`)
486
+ }
487
+ return this._mainActions.get(data.actionName)(data.data, req)
488
+ }
489
+
490
+ _registerLogMainAction () {
491
+ this.registerMainAction('log', (log, req) => {
492
+ this.logger[log.level](log.message, { ...req, ...log.meta, timestamp: log.timestamp })
493
+ })
494
+ }
495
+
496
+ async executeWorkerAction (actionName, data, options = {}, req) {
497
+ req.context.rootId = req.context.rootId || generateRequestId()
498
+ const timeout = options.timeout || 60000
499
+
500
+ const worker = options.worker
501
+ ? options.worker
502
+ : await this._workersManager.allocate(req, {
503
+ timeout
504
+ })
505
+
506
+ try {
507
+ const result = await worker.execute({
508
+ actionName,
509
+ data,
510
+ // we set just known props, to avoid cloning failures on express req properties
511
+ req: {
512
+ context: req.context,
513
+ template: req.template,
514
+ data: req.data,
515
+ options: req.options
516
+ }
517
+ }, {
518
+ // TODO add worker timeout
519
+ timeout,
520
+ timeoutErrorMessage: options.timeoutErrorMessage || ('Timeout during worker action ' + actionName),
521
+ executeMain: async (data) => {
522
+ return this._invokeMainAction(data, req)
523
+ }
524
+ })
525
+ this._workersManager.convertUint8ArrayToBuffer(result)
526
+ return result
527
+ } finally {
528
+ if (!options.worker) {
529
+ await worker.release(req)
530
+ }
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Periodical cleaning of folders where recipes are storing files like source html for pdf rendering
536
+ *
537
+ * @private
538
+ */
539
+ _startReaper (dir) {
540
+ const dirsToWatch = !Array.isArray(dir) ? [dir] : dir
541
+
542
+ if (this.options.autoTempCleanup === false) {
543
+ return
544
+ }
545
+
546
+ const threshold = this.options.reportTimeout > 180000 ? this.options.reportTimeout : 180000
547
+
548
+ this.logger.info(`Starting temp files cleanup with ${threshold}ms threshold`)
549
+
550
+ const reaper = new Reaper({ threshold })
551
+
552
+ dirsToWatch.forEach(d => reaper.watch(d))
553
+
554
+ reaper.start((err, files) => {
555
+ if (err) {
556
+ this.logger.error(`Failed to start auto cleanup: ${err.stack}`)
557
+ }
558
+ })
559
+
560
+ this._reaperTimerRef = setInterval(() => {
561
+ try {
562
+ reaper.start((err, files) => {
563
+ if (err) {
564
+ // NOT logging the error anymore because it was confusing users that something bad was happening
565
+ // this.logger.error('Failed to delete temp file: ' + err)
566
+ }
567
+ })
568
+ } catch (e) {
569
+ // NOT logging the error anymore because it was confusing users that something bad was happening
570
+ // catch error in case the reaper can not read directory
571
+ // this.logger.error('Failed to run reaper: ' + e)
572
+ }
573
+ }, 30000 /* check every 30s for old files */)
574
+
575
+ this._reaperTimerRef.unref()
576
+ }
577
+ }
578
+
579
+ module.exports = MainReporter