diff --git a/Gruntfile.js b/Gruntfile.js index 6c425b91d8..ffae32ce35 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -35,6 +35,7 @@ const path = require('path'); const buildUtils = require('./js/build-utils'); const BundlerGeneral = require("./js/bundler/bundler-general"); const LayoutTypeBundler = require('./js/layout-template-bundler'); +const TemplateBundler = require('./js/template-bundler/template-bundler'); const bundleConfig = require('./frontend/bundle-config.json'); const libs = require('./frontend/libs.json'); @@ -293,6 +294,17 @@ module.exports = grunt => { } }); + grunt.registerTask('bundle-templates', () => { + let templateBundler = new TemplateBundler({ + dirs: [ + 'client/res/templates', + 'client/modules/crm/res/templates', + ], + }); + + templateBundler.process(); + }); + grunt.registerTask('prepare-lib-original', () => { // Even though `npm ci` runs the same script, 'clean:start' deletes files. cp.execSync("node js/scripts/prepare-lib-original"); @@ -482,6 +494,7 @@ module.exports = grunt => { 'prepare-lib-original', 'transpile', 'bundle', + 'bundle-templates', 'uglify:bundle', 'copy:frontendLib', 'prepare-lib', diff --git a/application/Espo/Core/Utils/ClientManager.php b/application/Espo/Core/Utils/ClientManager.php index 980dc75ac2..f496456636 100644 --- a/application/Espo/Core/Utils/ClientManager.php +++ b/application/Espo/Core/Utils/ClientManager.php @@ -192,6 +192,7 @@ class ClientManager 'apiUrl' => 'api/v1', 'applicationName' => $this->config->get('applicationName', 'EspoCRM'), 'cacheTimestamp' => $cacheTimestamp, + 'appTimestamp' => $appTimestamp, 'loaderCacheTimestamp' => Json::encode($loaderCacheTimestamp), 'stylesheet' => $this->themeManager->getStylesheet(), 'runScript' => $runScript, @@ -205,6 +206,7 @@ class ClientManager 'faviconPath' => $faviconPath, 'ajaxTimeout' => $this->config->get('ajaxTimeout') ?? 60000, 'internalModuleList' => Json::encode($internalModuleList), + 'bundledModuleList' => Json::encode($this->getBundledModuleList()), 'applicationDescription' => $this->config->get('applicationDescription') ?? self::APP_DESCRIPTION, 'nonce' => $this->nonce, 'loaderParams' => Json::encode([ @@ -360,4 +362,24 @@ class ClientManager $modules ); } + + /** + * @return string[] + */ + private function getBundledModuleList(): array + { + if (!$this->isDeveloperMode()) { + return []; + } + + $modules = array_values(array_filter( + $this->module->getList(), + fn ($item) => $this->module->get([$item, 'bundled']) + )); + + return array_map( + fn ($item) => Util::fromCamelCase($item, '-'), + $modules + ); + } } diff --git a/client/src/app.js b/client/src/app.js index 375760cd1a..d68f7ca606 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -114,6 +114,16 @@ class App { */ this.internalModuleList = options.internalModuleList || []; + /** + * A list of bundled modules. + * + * @private + * @type {string[]} + */ + this.bundledModuleList = options.bundledModuleList || []; + + this.appTimestamp = options.appTimestamp; + this.initCache(options) .then(() => this.init(options, callback)); @@ -292,8 +302,10 @@ class App { webSocketManager = null /** + * An application timestamp. Used for asset cache busting and update detection. + * * @private - * @type {?int} + * @type {Number|null} */ appTimestamp = null @@ -305,54 +317,46 @@ class App { /** * @private + * @param {module:app~Options} options + * @return Promise */ initCache(options) { - let cacheTimestamp = options.cacheTimestamp || null; - let storedCacheTimestamp = null; - - if (this.useCache) { - this.cache = new Cache(cacheTimestamp); - - storedCacheTimestamp = this.cache.getCacheTimestamp(); - - if (cacheTimestamp) { - this.cache.handleActuality(cacheTimestamp); - } - else { - this.cache.storeTimestamp(); - } + if (!this.useCache) { + return Promise.resolve(); } - let handleActuality = () => { - if ( - !cacheTimestamp || - !storedCacheTimestamp || - cacheTimestamp !== storedCacheTimestamp - ) { - return caches.delete('espo'); - } + let cacheTimestamp = options.cacheTimestamp || null; - return new Promise(resolve => resolve()); - }; + this.cache = new Cache(cacheTimestamp); + + let storedCacheTimestamp = this.cache.getCacheTimestamp(); + + cacheTimestamp ? + this.cache.handleActuality(cacheTimestamp) : + this.cache.storeTimestamp(); + + if (!window.caches) { + return Promise.resolve(); + } return new Promise(resolve => { - if (!this.useCache) { - resolve(); - } + let deleteCache = !cacheTimestamp || + !storedCacheTimestamp || + cacheTimestamp !== storedCacheTimestamp; - if (!window.caches) { - resolve(); - } - - handleActuality() + ( + deleteCache ? + caches.delete('espo') : + Promise.resolve() + ) .then(() => caches.open('espo')) - .then(responseCache => { - this.responseCache = responseCache; + .then(cache => { + this.responseCache = cache; resolve(); }) .catch(() => { - console.error("Could not open `espo` cache."); + console.error(`Could not open 'espo' cache.`); resolve(); }); }); @@ -360,6 +364,8 @@ class App { /** * @private + * @param {module:app~Options} options + * @param {function} [callback] */ init(options, callback) { /** @type {Object.} */ @@ -393,7 +399,8 @@ class App { Promise .all([ this.settings.load(), - this.language.loadDefault() + this.language.loadDefault(), + this.initTemplateBundles(), ]) .then(() => { this.loader.setIsDeveloperMode(this.settings.get('isDeveloperMode')); @@ -412,8 +419,6 @@ class App { this.modelFactory = new ModelFactory(this.metadata); this.collectionFactory = new CollectionFactory(this.modelFactory, this.settings, this.metadata); - this.appTimestamp = this.settings.get('appTimestamp') || null; - if (this.settings.get('useWebSocket')) { this.webSocketManager = new WebSocketManager(this.settings); } @@ -1349,6 +1354,9 @@ class App { }); } + /** + * @private + */ initDomEventListeners() { $(document).on('keydown.espo.button', e => { if ( @@ -1368,11 +1376,94 @@ class App { e.preventDefault(); }); } + + /** + * @private + * @return {Promise} + */ + initTemplateBundles() { + if (!this.responseCache) { + return Promise.resolve(); + } + + const key = 'templateBundlesCached'; + + if (this.cache.get('app', key)) { + return Promise.resolve(); + } + + let files = ['client/lib/templates.tpl']; + + this.bundledModuleList.forEach(mod => { + let modPath = this.internalModuleList.includes(mod) ? + `client/modules/${mod}/` : + `client/custom/modules/${mod}/`; + + files.push(modPath + 'templates.tpl'); + }); + + let baseUrl = window.location.origin + window.location.pathname; + let timestamp = this.loader.getCacheTimestamp(); + + let promiseList = files.map(file => { + let url = new URL(baseUrl + this.basePath + file); + url.searchParams.append('t', this.appTimestamp); + + return new Promise(resolve => { + fetch(url) + .then(response => { + if (!response.ok) { + console.error(`Could not fetch ${url}.`); + resolve(); + + return; + } + + let promiseList = []; + + response.text().then(text => { + let index = text.indexOf('\n'); + + if (index <= 0) { + resolve(); + + return; + } + + let delimiter = text.slice(0, index + 1); + text = text.slice(index + 1); + + text.split(delimiter).forEach(item => { + let index = item.indexOf('\n'); + + let file = item.slice(0, index); + let content = item.slice(index + 1); + + let url = baseUrl + this.basePath + 'client/' + file; + + let urlObj = new URL(url); + urlObj.searchParams.append('r', timestamp); + + promiseList.push( + this.responseCache.put(urlObj, new Response(content)) + ); + }); + }); + + Promise.all(promiseList).then(() => resolve()); + }); + }); + }); + + return Promise.all(promiseList) + .then(() => { + this.cache.set('app', key, true); + }); + } } /** * @callback module:app~callback - * * @param {App} app A created application instance. */ @@ -1380,7 +1471,6 @@ class App { * Application options. * * @typedef {Object} module:app~Options - * * @property {string} [id] An application ID. * @property {string} [basePath] A base path. * @property {boolean} [useCache] Use cache. @@ -1388,7 +1478,9 @@ class App { * @property {Number} [ajaxTimeout] A default ajax request timeout. * @property {string} [internalModuleList] A list of internal modules. * Internal modules located in the `client/modules` directory. + * @property {string} [bundledModuleList] A list of bundled modules. * @property {Number|null} [cacheTimestamp] A cache timestamp. + * @property {Number|null} [appTimestamp] An application timestamp. */ Object.assign(App.prototype, Events); diff --git a/html/main.html b/html/main.html index 490cb13157..aee647651e 100644 --- a/html/main.html +++ b/html/main.html @@ -20,10 +20,12 @@ id: '{{applicationId}}', useCache: {{useCache}}, cacheTimestamp: {{cacheTimestamp}}, + appTimestamp: {{appTimestamp}}, basePath: '{{basePath}}', apiUrl: '{{apiUrl}}', ajaxTimeout: {{ajaxTimeout}}, internalModuleList: {{internalModuleList}}, + bundledModuleList: {{bundledModuleList}}, }, app => { {{runScript}} }); diff --git a/js/template-bundler/template-bundler.js b/js/template-bundler/template-bundler.js new file mode 100644 index 0000000000..86035cedb9 --- /dev/null +++ b/js/template-bundler/template-bundler.js @@ -0,0 +1,111 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +const fs = require('fs'); +const {globSync} = require('glob'); + +class TemplateBundler { + + /** + * @param {{ + * dirs?: string[], + * dest?: string, + * clientDir?: string, + * }} config + */ + constructor(config) { + this.dirs = this.dirs ?? ['client/res/templates']; + this.dest = config.dest ?? 'client/lib/templates.tpl'; + this.clientDir = config.clientDir ?? 'client'; + } + + /** + * @return {string} + */ + process() { + /** @type {string[]} */ + let allFiles = []; + + this.dirs.forEach(dir => { + let files = globSync(dir + '/**/*.tpl') + .map(file => file.replaceAll('\\', '/')); + + allFiles.push(...files); + }); + + let contents = []; + + allFiles.forEach(file => { + let content = fs.readFileSync(file, 'utf-8'); + + if (file.indexOf(this.clientDir) !== 0) { + throw new Error(`File ${file} not in the client dir.`); + } + + let path = file.slice(this.clientDir.length + 1); + + content = path + '\n' + content; + + contents.push(content); + }); + + let delimiter = this.#generateDelimiter(contents); + + let result = delimiter + '\n' + contents.join('\n' + delimiter + '\n'); + + fs.writeFileSync(this.dest, result, 'utf8'); + + console.log(` ${contents.length} templates bundled in ${this.dest}.`); + } + + /** + * @param {string[]} contents + * @return {string} + */ + #generateDelimiter(contents) { + let delimiter = '_delimiter_' + Math.random().toString(36).slice(2); + + let retry = false; + + for (let content of contents) { + if (content.includes(delimiter)) { + retry = true; + + break; + } + } + + if (retry) { + return this.#generateDelimiter(contents); + } + + return delimiter; + } +} + +module.exports = TemplateBundler; diff --git a/schema/module.json b/schema/module.json index 402492c08d..1f63a3c3a6 100644 --- a/schema/module.json +++ b/schema/module.json @@ -12,6 +12,10 @@ "jsTranspiled": { "type": "boolean", "description": "Indicates that JS files in the module are transpiled." + }, + "bundled": { + "type": "boolean", + "description": "Indicates that frontend files in the module are bundled." } } }