diff --git a/.eslintrc.js b/.eslintrc.js index e4fcb8b..48e05a8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,7 +7,7 @@ module.exports = { window: true, hepsiBus: true, global: true, - jest: true, + jest: true }, parserOptions: { ecmaFeatures: { @@ -46,7 +46,13 @@ module.exports = { 'no-nested-ternary': 'off', 'no-underscore-dangle': 'off', 'consistent-return': 'off', - 'array-callback-return': 'off' + 'array-callback-return': 'off', + 'no-restricted-syntax': [ + 'error', + 'FunctionExpression', + 'WithStatement', + "BinaryExpression[operator='in']" + ] }, env: { jest: true, diff --git a/config/emptyModule.js b/config/emptyModule.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/config/emptyModule.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/config/string.js b/config/string.js index d7e5fed..6358e7a 100644 --- a/config/string.js +++ b/config/string.js @@ -7,12 +7,18 @@ const voltranConfig = require('../voltran.config'); const prometheusFile = voltranConfig.monitoring.prometheus; -function replaceString () { - const data = [ - { search: '__V_COMPONENTS__', replace: normalizeUrl(voltranConfig.routing.components), flags: 'g' }, +function replaceString() { + const data = [ + { + search: '__V_COMPONENTS__', + replace: normalizeUrl(voltranConfig.routing.components), + flags: 'g' + }, { search: '__APP_CONFIG__', - replace: normalizeUrl(`${voltranConfig.appConfigFile.output.path}/${voltranConfig.appConfigFile.output.name}.js`), + replace: normalizeUrl( + `${voltranConfig.appConfigFile.output.path}/${voltranConfig.appConfigFile.output.name}.js` + ), flags: 'g' }, { @@ -20,14 +26,57 @@ function replaceString () { replace: normalizeUrl(`${voltranConfig.inputFolder}/assets.json`), flags: 'g' }, - { search: '__V_DICTIONARY__', replace: normalizeUrl(voltranConfig.routing.dictionary), flags: 'g' }, - { search: '@voltran/core', replace: normalizeUrl(path.resolve(__dirname, '../src/index')), flags: 'g' }, + { + search: '__V_DICTIONARY__', + replace: normalizeUrl(voltranConfig.routing.dictionary), + flags: 'g' + }, + { + search: '__V_REQUEST_CONFIGS__', + replace: normalizeUrl( + voltranConfig.requestConfigs || path.resolve(__dirname, './emptyModule.js') + ), + flags: 'g' + }, + { + search: '__V_PREVIEW__', + replace: normalizeUrl(voltranConfig.preview || path.resolve(__dirname, './emptyModule.js')), + flags: 'g' + }, + { + search: '@voltran/core', + replace: normalizeUrl(path.resolve(__dirname, '../src/index')), + flags: 'g' + }, + { + search: '@voltran/server', + replace: normalizeUrl(path.resolve(__dirname, '../src/server')), + flags: 'g' + }, + { + search: '__V_MAIN__', + replace: normalizeUrl( + voltranConfig.entry.main || path.resolve(__dirname, './emptyModule.js') + ), + flags: 'g' + }, + { + search: '__V_SERVER__', + replace: normalizeUrl( + voltranConfig.entry.server || path.resolve(__dirname, './emptyModule.js') + ), + flags: 'g' + }, { search: '"__V_styles__"', replace: getStyles() } ]; - data.push({ search: '__V_PROMETHEUS__', replace: normalizeUrl(prometheusFile ? prometheusFile : '../lib/tools/prom.js'), flags: 'g' }); + data.push({ + search: '__V_PROMETHEUS__', + replace: normalizeUrl(prometheusFile || '../lib/tools/prom.js'), + flags: 'g' + }); - return data; + return data; } module.exports = replaceString; diff --git a/jest.config.js b/jest.config.js index 19e22c1..57b22a3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,3 @@ -// Jest configuration -// https://facebook.github.io/jest/docs/en/configuration.html module.exports = { verbose: true, automock: false, @@ -11,6 +9,11 @@ module.exports = { '!src/public/**', '!src/tools/**' ], + env: { + production: { + plugins: ['transform-es2015-modules-commonjs'] + } + }, coverageDirectory: '/coverage', globals: { window: true, diff --git a/lib/os.js b/lib/os.js index 96b8a48..d23b3d4 100644 --- a/lib/os.js +++ b/lib/os.js @@ -1,9 +1,13 @@ const path = require('path'); function normalizeUrl(url) { - const urlArray = url.split(path.sep); - - return urlArray.join('/'); + if (url) { + const urlArray = url?.split(path.sep); + + return urlArray.join('/'); + } + + return ''; } -module.exports = normalizeUrl; \ No newline at end of file +module.exports = normalizeUrl; diff --git a/package.json b/package.json index d56916d..3fb19b1 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "classnames": "2.2.6", "clean-webpack-plugin": "1.0.0", "cli-color": "^2.0.0", + "colors": "^1.4.0", "compose-middleware": "5.0.0", "compression": "^1.7.4", "cookie-parser": "1.4.3", @@ -59,6 +60,7 @@ "file-loader": "1.1.11", "helmet": "3.21.3", "hiddie": "^1.0.0", + "history": "^5.3.0", "husky": "^3.1.0", "identity-obj-proxy": "3.0.0", "intersection-observer": "0.7.0", @@ -132,5 +134,9 @@ "commitLimit": false, "template": "changelog-template.hbs", "package": true + }, + "peerDependencies": { + "react": ">=16.13.0", + "react-dom": ">=16.13.0" } } diff --git a/src/index.js b/src/index.js index d534a79..e0624ba 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,18 @@ -import withBaseComponent from './universal/partials/withBaseComponent'; -import { SERVICES } from './universal/utils/constants'; +import voltran from './universal/partials/withBaseComponent'; +import apiService, { + ClientApiManager, + ServerApiManager, + apiServiceMiddleware +} from './universal/core/apiService'; +import requestDispatcher from './universal/utils/requestDispatcher'; +import useRequestDispatcher from './universal/hooks/useRequestDispatcher'; -export default { - withBaseComponent, - SERVICES +export default voltran; +export { + ClientApiManager, + ServerApiManager, + apiService, + apiServiceMiddleware, + requestDispatcher, + useRequestDispatcher }; diff --git a/src/main.js b/src/main.js index fa6bdf2..e9000fa 100644 --- a/src/main.js +++ b/src/main.js @@ -6,12 +6,14 @@ import Hiddie from 'hiddie'; import http from 'http'; import voltranConfig from '../voltran.config'; import prom from 'prom-client'; -import {HTTP_STATUS_CODES} from './universal/utils/constants'; +import { HTTP_STATUS_CODES } from './universal/utils/constants'; + +const voltranMain = require('__V_MAIN__'); const enablePrometheus = voltranConfig.monitoring.prometheus; function triggerMessageListener(worker) { - worker.on('message', function (message) { + worker.on('message', function(message) { if (message?.options?.forwardAllWorkers) { sendMessageToAllWorkers(message); } @@ -19,28 +21,33 @@ function triggerMessageListener(worker) { } function sendMessageToAllWorkers(message) { - Object.keys(cluster.workers).forEach(function (key) { + Object.keys(cluster.workers).forEach(function(key) { const worker = cluster.workers[key]; worker.send({ - msg: message.msg, + msg: message.msg }); }, this); } -cluster.on('fork', (worker) => { +cluster.on('fork', worker => { triggerMessageListener(worker); }); -if (cluster.isMaster) { - for (let i = 0; i < os.cpus().length; i += 1) { +const DEFAULT_CPU_COUNT = os.cpus().length; + +function forkClusters(cpuCount = DEFAULT_CPU_COUNT) { + for (let i = 0; i < cpuCount; i += 1) { cluster.fork(); } cluster.on('exit', worker => { logger.error(`Worker ${worker.id} died`); - cluster.fork(); + const newWorker = cluster.fork(); + cluster.emit('message', newWorker, 'NEW_WORKER'); }); +} +if (cluster.isMaster) { if (enablePrometheus) { const aggregatorRegistry = new prom.AggregatorRegistry(); const metricsPort = voltranConfig.port + 1; @@ -52,7 +59,7 @@ if (cluster.isMaster) { return res.end(await aggregatorRegistry.clusterMetrics()); } res.statusCode = HTTP_STATUS_CODES.NOT_FOUND; - res.end(JSON.stringify({message: 'not found'})); + res.end(JSON.stringify({ message: 'not found' })); }); http.createServer(hiddie.run).listen(metricsPort, () => { @@ -63,7 +70,14 @@ if (cluster.isMaster) { ); }); } + + if (voltranConfig.entry.main) { + const voltranMainFile = voltranMain.default; + forkClusters(voltranMainFile.cpuCount); + voltranMainFile.load(cluster); + } +} else if (voltranConfig.entry.server) { + require('__V_SERVER__'); } else { - // eslint-disable-next-line global-require require('./server'); } diff --git a/src/middleware.js b/src/middleware.js new file mode 100644 index 0000000..829d94f --- /dev/null +++ b/src/middleware.js @@ -0,0 +1,190 @@ +/* istanbul ignore file */ +import newrelic from './universal/tools/newrelic/newrelic'; + +import cookieParser from 'cookie-parser'; +import { compose } from 'compose-middleware'; +import compression from 'compression'; +import path from 'path'; +import Hiddie from 'hiddie'; +import serveStatic from 'serve-static'; +import prom from 'prom-client'; +import helmet from 'helmet'; +import url from 'url'; +import xss from 'xss'; + +import Welcome from './universal/partials/Welcome'; +import render from './render'; +import registerControllers from './api/controllers'; +import renderMultiple from './renderMultiple'; + +import { createCacheManagerInstance } from './universal/core/cache/cacheUtils'; + +import { HTTP_STATUS_CODES } from './universal/utils/constants'; + +import voltranConfig from '../voltran.config'; + +const enablePrometheus = voltranConfig.monitoring.prometheus; +let Prometheus; + +if (enablePrometheus) { + // eslint-disable-next-line global-require + Prometheus = require('__V_PROMETHEUS__'); +} + +const fragmentManifest = require('__V_DICTIONARY__'); + +process.on('unhandledRejection', (reason, p) => { + console.error('Unhandled Rejection at:', p, 'reason:', reason); + process.exit(1); +}); + +process.on('message', message => { + handleProcessMessage(message); +}); + +const fragments = []; + +Object.keys(fragmentManifest).forEach(index => { + const fragmentUrl = fragmentManifest[index].path; + const arr = fragmentUrl.split(path.sep); + const name = arr[arr.length - 1]; + fragments.push(name); +}); + +const handleProcessMessage = message => { + if (message?.msg?.action === 'deleteallcache') { + createCacheManagerInstance().removeAll(); + } else if (message?.msg?.action === 'deletecache') { + createCacheManagerInstance().remove(message?.msg?.key); + } +}; + +const handleUrls = async (req, res, next) => { + if (req.url === '/' && req.method === 'GET') { + res.html(Welcome()); + } else if (req.url === '/metrics' && req.method === 'GET' && !enablePrometheus) { + res.setHeader('Content-Type', prom.register.contentType); + res.end(prom.register.metrics()); + } else if (req.url === '/status' && req.method === 'GET') { + res.json({ success: true, version: process.env.GO_PIPELINE_LABEL || '1.0.0', fragments }); + } else if ((req.url === '/statusCheck' || req.url === '/statuscheck') && req.method === 'GET') { + res.json({ success: true, version: process.env.GO_PIPELINE_LABEL || '1.0.0', fragments }); + } else if (req.url === '/deleteallcache' && req.method === 'GET') { + process.send({ + msg: { + action: 'deleteallcache' + }, + options: { + forwardAllWorkers: true + } + }); + res.json({ success: true }); + } else if (req.path === '/deletecache' && req.method === 'GET') { + if (req?.query?.key) { + process.send({ + msg: { + action: 'deletecache', + key: req?.query?.key + }, + options: { + forwardAllWorkers: true + } + }); + res.json({ success: true }); + } else { + res.json({ success: false }); + } + } else { + newrelic?.setTransactionName?.(req.path); + next(); + } +}; + +const cors = async (req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, POST, DELETE, HEAD, OPTIONS'); + + if (req.method === 'OPTIONS') { + res.writeHead(HTTP_STATUS_CODES.OK); + res.end(); + + return; + } + + next(); +}; + +const utils = async (req, res, next) => { + res.json = json => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(json)); + }; + + res.html = html => { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(html); + }; + + res.status = code => { + res.statusCode = code; + return res; + }; + + next(); +}; + +const locals = async (req, res, next) => { + const parsedUrl = url.parse(req.url, true); + + req.query = JSON.parse(xss(JSON.stringify(parsedUrl.query))); + req.path = xss(parsedUrl.pathname); + req.url = xss(req.url); + + if (req.headers['set-cookie']) { + req.headers.cookie = req.headers.cookie || req.headers['set-cookie']?.join(); + delete req.headers['set-cookie']; + } + + res.locals = {}; + res.locals.startEpoch = new Date(); + + next(); +}; + +export const hiddie = Hiddie(async (err, req, res) => { + res.end(); +}); + +export const prepareMiddlewares = () => { + hiddie.use(compression()); + hiddie.use(locals); + hiddie.use(helmet()); + hiddie.use(cors); + hiddie.use('/', serveStatic(`${voltranConfig.distFolder}/public`)); + hiddie.use(cookieParser()); + hiddie.use(utils); + hiddie.use(handleUrls); + + if (enablePrometheus) { + Prometheus.injectMetricsRoute(hiddie); + Prometheus.startCollection(); + } + + registerControllers(hiddie); + hiddie.use('/components/:components/:path*', renderMultiple); + hiddie.use('/components/:components', renderMultiple); + hiddie.use(render); +}; + +export const composedMiddlewares = () => { + return compose([ + compression(), + locals, + helmet(), + serveStatic(`${voltranConfig.distFolder}/public`), + cookieParser(), + utils, + handleUrls, + render + ]); +}; diff --git a/src/render.js b/src/render.js index 7a32518..3ac3160 100644 --- a/src/render.js +++ b/src/render.js @@ -1,8 +1,7 @@ import xss from 'xss'; import { matchUrlInRouteConfigs } from './universal/core/route/routeUtils'; -import Preview from './universal/components/Preview'; -import { HTTP_STATUS_CODES } from './universal/utils/constants'; +import { BLACKLIST_OUTPUT, HTTP_STATUS_CODES } from './universal/utils/constants'; import metrics from './metrics'; import { renderComponent, @@ -13,16 +12,18 @@ import { } from './universal/service/RenderService'; import Component from './universal/model/Component'; import logger from './universal/utils/logger'; +import omit from 'lodash/omit'; +import { getPreviewFile } from './universal/utils/previewHelper'; const appConfig = require('__APP_CONFIG__'); -// eslint-disable-next-line consistent-return -export default async (req, res) => { +const render = async (req, res) => { const isWithoutStateValue = isWithoutState(req.query); const pathParts = xss(req.path) .split('/') .filter(part => part); - const componentPath = `/${pathParts.join('/')}`; + const componentPath = `/${pathParts?.[0]}`; + const isPreviewValue = isPreview(req.query); const routeInfo = matchUrlInRouteConfigs(componentPath); @@ -36,9 +37,11 @@ export default async (req, res) => { url: xss(req.url) .replace(componentPath, '/') .replace('//', '/'), - userAgent: Buffer.from(req.headers['user-agent'], 'utf-8').toString('base64'), + userAgent: Buffer.from(req.headers['user-agent'] || [], 'utf-8').toString('base64'), headers: JSON.parse(xss(JSON.stringify(req.headers))), - isWithoutState: isWithoutStateValue + isWithoutState: isWithoutStateValue, + isPreview: isPreviewValue, + componentPath }; const component = new Component(routeInfo.path); @@ -61,9 +64,9 @@ export default async (req, res) => { scripts, activeComponent, componentName, - seoState, isPreviewQuery, responseOptions, + ...responseData } = renderResponse; const statusCode = responseOptions?.isPartialContent @@ -72,8 +75,8 @@ export default async (req, res) => { if (!isPreview(context.query)) { const html = renderLinksAndScripts(output, '', ''); - - res.status(statusCode).json({ html, scripts, style: links, activeComponent, seoState }); + const otherParams = omit(responseData, BLACKLIST_OUTPUT); + res.status(statusCode).json({ html, scripts, style: links, activeComponent, ...otherParams }); metrics.fragmentRequestDurationMicroseconds .labels(componentName, isWithoutHTML(context.query) ? '1' : '0') @@ -82,9 +85,21 @@ export default async (req, res) => { const voltranEnv = appConfig.voltranEnv || 'local'; if (voltranEnv !== 'prod' && isPreviewQuery) { - res.status(statusCode).html(Preview([fullHtml].join('\n'))); + const requestDispatcherResponse = await renderComponent( + new Component('/RequestDispatcher'), + context + ); + const requestDispatcherFullHtml = requestDispatcherResponse.fullHtml; + const PreviewFile = getPreviewFile(context.query); + const body = [requestDispatcherFullHtml, fullHtml].join('\n'); + + const response = PreviewFile({ body, componentName }); + + res.status(statusCode).html(response); } else { - res.status(HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR).html('

Aradığınız sayfa bulunamadı...

'); + res + .status(HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR) + .html('

Aradığınız sayfa bulunamadı...

'); } } } else { @@ -93,3 +108,5 @@ export default async (req, res) => { }); } }; + +export default render; diff --git a/src/renderMultiple.js b/src/renderMultiple.js index dc2dcd8..acf3e87 100644 --- a/src/renderMultiple.js +++ b/src/renderMultiple.js @@ -1,20 +1,43 @@ /* eslint-disable no-param-reassign */ +import async from 'async'; import { matchUrlInRouteConfigs } from './universal/core/route/routeUtils'; import Component from './universal/model/Component'; import Renderer from './universal/model/Renderer'; -import async from 'async'; -import Preview from './universal/components/Preview'; -import { isPreview, isWithoutHTML } from './universal/service/RenderService'; +import { + isRequestDispatcher, + isPreview, + isWithoutHTML, + isWithoutState +} from './universal/service/RenderService'; import metrics from './metrics'; import { HTTP_STATUS_CODES } from './universal/utils/constants'; import logger from './universal/utils/logger'; +import { getPreviewFile } from './universal/utils/previewHelper'; + +const getRenderOptions = req => { + const isPreviewValue = isPreview(req.query) || false; + const isWithoutHTMLValue = isWithoutHTML(req.query) || false; + const isWithoutStateValue = isWithoutState(req.query) || false; + + return { + isPreview: isPreviewValue, + isWithoutHTML: isWithoutHTMLValue, + isWithoutState: isWithoutStateValue + }; +}; + +function getRenderer(name, req) { + const { query, cookies, url, headers, params } = req; + const path = `/${params?.path || ''}`; + const userAgent = headers['user-agent']; -function getRenderer(name, query, cookies, url, path, userAgent) { const componentPath = Component.getComponentPath(name); const routeInfo = matchUrlInRouteConfigs(componentPath); + const renderOptions = getRenderOptions(req); if (routeInfo) { const urlWithPath = url.replace('/', path); + const fullComponentPath = `/components/${req.params.components ?? ''}`; const context = { path, @@ -22,6 +45,9 @@ function getRenderer(name, query, cookies, url, path, userAgent) { cookies, url: urlWithPath, userAgent, + headers, + componentPath: fullComponentPath, + ...renderOptions }; if (Component.isExist(componentPath)) { @@ -35,22 +61,16 @@ function getRenderer(name, query, cookies, url, path, userAgent) { } function iterateServicesMap(servicesMap, callback) { - Object.getOwnPropertySymbols(servicesMap).forEach(serviceName => { - const endPoints = servicesMap[serviceName]; + Object.getOwnPropertyNames(servicesMap).forEach(serviceName => { + const requests = servicesMap[serviceName]; - Object.keys(endPoints).forEach(endPointName => { - callback(serviceName, endPointName); - }); + callback(serviceName, requests); }); } -function reduceServicesMap(servicesMap, callback, initialValue) { - return Object.getOwnPropertySymbols(servicesMap).map(serviceName => { - const endPoints = servicesMap[serviceName]; - - return Object.keys(endPoints).reduce((obj, endPointName) => { - return callback(serviceName, endPointName, obj); - }, initialValue); +function reduceServicesMap(servicesMap, callback, obj) { + return Object.getOwnPropertyNames(servicesMap).map(serviceName => { + return callback(serviceName, obj); }); } @@ -58,9 +78,7 @@ function getHashes(renderers) { return renderers .filter(renderer => renderer.servicesMap) .reduce((hashes, renderer) => { - iterateServicesMap(renderer.servicesMap, (serviceName, endPointName) => { - const requests = renderer.servicesMap[serviceName][endPointName]; - + iterateServicesMap(renderer.servicesMap, (serviceName, requests) => { requests.forEach(request => { if (hashes[request.hash]) { hashes[request.hash].occurrence += 1; @@ -93,12 +111,8 @@ function incWinnerScore(winner, hashes) { hashes[winner.hash].score += 1; } -function putWinnerMap(serviceName, endPointName, winnerMap, winner) { - if (winnerMap[serviceName]) { - winnerMap[serviceName][endPointName] = winner; - } else { - winnerMap[serviceName] = { [endPointName]: winner }; - } +function putWinnerMap(serviceName, winnerMap, winner) { + winnerMap[serviceName] = winner; } async function setInitialStates(renderers) { @@ -107,21 +121,23 @@ async function setInitialStates(renderers) { const promises = renderers .filter(renderer => renderer.servicesMap) .reduce((promises, renderer) => { - iterateServicesMap(renderer.servicesMap, (serviceName, endPointName) => { - const requests = renderer.servicesMap[serviceName][endPointName]; - + iterateServicesMap(renderer.servicesMap, (serviceName, requests) => { const winner = getWinner(requests, hashes); incWinnerScore(winner, hashes); - putWinnerMap(serviceName, endPointName, renderer.winnerMap, winner); + putWinnerMap(serviceName, renderer.winnerMap, winner); if (!promises[winner.hash]) { promises[winner.hash] = callback => { winner .execute() .then(response => callback(null, response)) - .catch(exception => - callback(new Error(`${winner.uri} : ${exception.message}`), null) - ); + .catch(exception => { + if (renderer.component.object.byPassWhenFailed) { + callback(null, { data: { isFailed: true } }); + } else { + callback(new Error(`${winner.uri} : ${exception.message}`), null); + } + }); }; } }); @@ -144,9 +160,9 @@ async function setInitialStates(renderers) { renderer.setInitialState( reduceServicesMap( renderer.winnerMap, - (serviceName, endPointName, obj) => { - const request = renderer.winnerMap[serviceName][endPointName]; - obj[endPointName] = results[request.hash]; + (serviceName, obj) => { + const request = renderer.winnerMap[serviceName]; + obj[serviceName] = results[request.hash]; return obj; }, {} @@ -159,40 +175,68 @@ async function setInitialStates(renderers) { } async function getResponses(renderers) { - return (await Promise.all(renderers.map(renderer => renderer.render()))) + const responses = (await Promise.all(renderers.map(renderer => renderer.render()))) .filter(result => result.value != null) .reduce((obj, item) => { const el = obj; - const name = `${item.key}_${item.id}`; + const name = `${item.key}`; if (!el[name]) el[name] = item.value; return el; }, {}); + + return responses; } -async function getPreview(responses, requestCount) { - return Preview( - [...Object.keys(responses).map(name => responses[name].fullHtml)].join('\n'), - `${requestCount} request!` - ); +async function getPreview(responses, requestCount, req) { + const componentNames = Object.keys(responses); + const PreviewFile = getPreviewFile(req.query); + + const content = Object.keys(responses).map(name => { + const componentName = responses?.[name]?.activeComponent?.componentName ?? ''; + return getLayoutWithClass(componentName, responses[name].fullHtml); + }); + const body = [...content].join('\n'); + + return PreviewFile({ + body, + requestCount, + componentNames + }); } -// eslint-disable-next-line consistent-return -export default async (req, res) => { - const renderers = req.params.components +const DEFAULT_PARTIALS = ['RequestDispatcher']; + +export const getPartials = req => { + const useRequestDispatcher = isRequestDispatcher(req.query); + + const reqPartials = req.params.components .split(',') .filter((value, index, self) => self.indexOf(value) === index) - .map(name => - getRenderer( - name, - req.query, - req.cookies, - req.url, - `/${req.params.path || ''}`, - req.headers['user-agent'] - ) - ) + .filter(item => !DEFAULT_PARTIALS.includes(item)); + + const partials = [...(useRequestDispatcher ? DEFAULT_PARTIALS : []), ...reqPartials]; + + return partials; +}; + +function cr(condition, ok, cancel) { + return condition ? ok : cancel || ''; +} + +const getLayoutWithClass = (name, html, id = '', style = null) => { + const idAttr = cr(id !== '', `id=${id}`); + const styleAttr = cr(style !== null, `style=${style}`); + + return `
${html}
`; +}; + +const renderMultiple = async (req, res) => { + const partials = getPartials(req); + + const renderers = partials + .map(name => getRenderer(name, req)) .filter(renderer => renderer != null); if (!renderers.length) { @@ -217,7 +261,7 @@ export default async (req, res) => { const responses = await getResponses(renderers); if (isPreview(req.query)) { - const preview = await getPreview(responses, requestCount); + const preview = await getPreview(responses, requestCount, req); res.html(preview); } else { res.json(responses); @@ -227,3 +271,5 @@ export default async (req, res) => { .observe(Date.now() - res.locals.startEpoch); } }; + +export default renderMultiple; diff --git a/src/server.js b/src/server.js index b93ee03..2c653fc 100644 --- a/src/server.js +++ b/src/server.js @@ -1,191 +1,20 @@ -/* istanbul ignore file */ -import newrelic from './universal/tools/newrelic/newrelic'; - -import cookieParser from 'cookie-parser'; -import { compose } from 'compose-middleware'; -import compression from 'compression'; -import path from 'path'; -import Hiddie from 'hiddie'; import http from 'http'; -import serveStatic from 'serve-static'; -import prom from 'prom-client'; -import helmet from 'helmet'; -import url from 'url'; -import xss from 'xss'; - -import Welcome from './universal/partials/Welcome'; -import render from './render'; -import registerControllers from './api/controllers'; -import renderMultiple from './renderMultiple'; - -import { createCacheManagerInstance } from './universal/core/cache/cacheUtils'; - -import { HTTP_STATUS_CODES } from './universal/utils/constants'; import voltranConfig from '../voltran.config'; +import { hiddie, composedMiddlewares, prepareMiddlewares } from './middleware'; -const enablePrometheus = voltranConfig.monitoring.prometheus; -let Prometheus; - -if (enablePrometheus) { - // eslint-disable-next-line global-require - Prometheus = require('__V_PROMETHEUS__'); -} - -const fragmentManifest = require('__V_DICTIONARY__'); - -process.on('unhandledRejection', (reason, p) => { - console.error('Unhandled Rejection at:', p, 'reason:', reason); - process.exit(1); -}); - -process.on('message', message => { - handleProcessMessage(message); -}); - -const fragments = []; - -Object.keys(fragmentManifest).forEach(index => { - const fragmentUrl = fragmentManifest[index].path; - const arr = fragmentUrl.split(path.sep); - const name = arr[arr.length - 1]; - fragments.push(name); -}); - -const handleProcessMessage = message => { - if (message?.msg?.action === 'deleteallcache') { - createCacheManagerInstance().removeAll(); - } else if (message?.msg?.action === 'deletecache') { - createCacheManagerInstance().remove(message?.msg?.key); - } -}; - -const handleUrls = async (req, res, next) => { - if (req.url === '/' && req.method === 'GET') { - res.html(Welcome()); - } else if (req.url === '/metrics' && req.method === 'GET' && !enablePrometheus) { - res.setHeader('Content-Type', prom.register.contentType); - res.end(prom.register.metrics()); - } else if (req.url === '/status' && req.method === 'GET') { - res.json({ success: true, version: process.env.GO_PIPELINE_LABEL || '1.0.0', fragments }); - } else if ((req.url === '/statusCheck' || req.url === '/statuscheck') && req.method === 'GET') { - res.json({ success: true, version: process.env.GO_PIPELINE_LABEL || '1.0.0', fragments }); - } else if (req.url === '/deleteallcache' && req.method === 'GET') { - process.send({ - msg: { - action: 'deleteallcache' - }, - options: { - forwardAllWorkers: true - } - }); - res.json({ success: true }); - } else if (req.path === '/deletecache' && req.method === 'GET') { - if (req?.query?.key) { - process.send({ - msg: { - action: 'deletecache', - key: req?.query?.key - }, - options: { - forwardAllWorkers: true - } - }); - res.json({ success: true }); - } else { - res.json({ success: false }); - } - } else { - newrelic?.setTransactionName?.(req.path); - next(); - } -}; - -const cors = async (req, res, next) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, POST, DELETE, HEAD, OPTIONS'); - - if (req.method === 'OPTIONS') { - res.writeHead(HTTP_STATUS_CODES.OK); - res.end(); +const isDebug = voltranConfig.dev; +const customServer = voltranConfig.entry.server; +const canStartServer = process.env.NODE_ENV === 'production' && (!customServer || isDebug); - return; - } - - next(); -}; - -const utils = async (req, res, next) => { - res.json = json => { - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(json)); - }; - - res.html = html => { - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(html); - }; - - res.status = code => { - res.statusCode = code; - return res; - }; - - next(); -}; - -const locals = async (req, res, next) => { - const parsedUrl = url.parse(req.url, true); - - req.query = JSON.parse(xss(JSON.stringify(parsedUrl.query))); - req.path = xss(parsedUrl.pathname); - req.url = xss(req.url); - - if (req.headers['set-cookie']) { - req.headers.cookie = req.headers.cookie || req.headers['set-cookie']?.join(); - delete req.headers['set-cookie']; - } - - res.locals = {}; - res.locals.startEpoch = new Date(); - - next(); +const launchServer = () => { + prepareMiddlewares(); + http.createServer(hiddie.run).listen(voltranConfig.port); }; -if (process.env.NODE_ENV === 'production') { - const hiddie = Hiddie(async (err, req, res) => { - res.end(); - }); - hiddie.use(compression()); - hiddie.use(locals); - hiddie.use(helmet()); - hiddie.use(cors); - hiddie.use('/', serveStatic(`${voltranConfig.distFolder}/public`)); - hiddie.use(cookieParser()); - hiddie.use(utils); - hiddie.use(handleUrls); - - if (enablePrometheus) { - Prometheus.injectMetricsRoute(hiddie); - Prometheus.startCollection(); - } - - registerControllers(hiddie); - hiddie.use('/components/:components/:path*', renderMultiple); - hiddie.use('/components/:components', renderMultiple); - hiddie.use(render); - http.createServer(hiddie.run).listen(voltranConfig.port); +if (canStartServer) { + launchServer(); } -export default () => { - return compose([ - compression(), - locals, - helmet(), - serveStatic(`${voltranConfig.distFolder}/public`), - cookieParser(), - utils, - handleUrls, - render - ]); -}; +export default composedMiddlewares; +export { launchServer, hiddie }; diff --git a/src/universal/components/ClientApp.js b/src/universal/components/ClientApp.js index bc15869..84040aa 100644 --- a/src/universal/components/ClientApp.js +++ b/src/universal/components/ClientApp.js @@ -1,7 +1,7 @@ import React from 'react'; function ClientApp({ children }) { - return
{children}
; + return
{children}
; } export default ClientApp; diff --git a/src/universal/components/Preview.js b/src/universal/components/Preview.js index 87e7f17..af881ce 100644 --- a/src/universal/components/Preview.js +++ b/src/universal/components/Preview.js @@ -1,7 +1,7 @@ const appConfig = require('__APP_CONFIG__'); -export default (body, title = null) => { - const additionalTitle = title ? ` - ${title}` : ''; +export default ({ body, componentName = '' }) => { + const additionalTitle = componentName ? ` - ${componentName}` : ''; function cr(condition, ok, cancel) { return condition ? ok : cancel || ''; @@ -13,7 +13,12 @@ export default (body, title = null) => { Preview${additionalTitle} - + + ${cr( + appConfig.voltranCommonUrl, + ``, + '' + )} ${cr( appConfig.showPreviewFrame, ` - ${styleTags} + ${welcomeStyle()} - ${PartialList} + +
+
+ +
+
+
+ ${PartialCards} +
+
+
+ + `; }; diff --git a/src/universal/partials/Welcome/partials.js b/src/universal/partials/Welcome/partials.js index db298e6..91f1289 100644 --- a/src/universal/partials/Welcome/partials.js +++ b/src/universal/partials/Welcome/partials.js @@ -1,14 +1,20 @@ -const components = require('__V_COMPONENTS__'); +import components from '../../core/route/components'; -const partials = []; +const preview = require('__V_PREVIEW__'); -Object.keys(components.default).forEach(path => { - const info = components.default[path]; - partials.push({ - name: info.fragmentName, - url: path, - status: info.status - }); +const partials = []; +const BLACKLIST = ['REQUEST_DISPATCHER']; +Object.keys(components).forEach(path => { + const info = components[path]; + if (!BLACKLIST.includes(info.name)) { + partials.push({ + name: info.fragmentName, + url: path, + status: info?.fragment?.previewStatus || info.status + }); + } }); +const pages = preview?.default?.pages || []; +partials.push(...pages); export default partials; diff --git a/src/universal/partials/Welcome/styled.js b/src/universal/partials/Welcome/styled.js deleted file mode 100644 index 0e16937..0000000 --- a/src/universal/partials/Welcome/styled.js +++ /dev/null @@ -1,118 +0,0 @@ -import styled from 'styled-components'; - -const STATUS_COLOR = { - live: '#8dc63f', - dev: '#FF6000' -}; -export const List = styled.ul` - list-style: none; - margin: 0; - padding: 0; -`; - -export const HeaderName = styled.div` - font-size: 36px; - font-weight: bold; - margin: 10px; -`; - -export const ListItem = styled.li` - padding: 20px; - border-radius: 2px; - background: white; - box-shadow: 0 2px 1px rgba(170, 170, 170, 0.25); - position: relative; - display: inline-block; - vertical-align: top; - height: 120px; - width: 320px; - margin: 10px; - cursor: pointer; - - &:hover { - background: #efefef; - } - - @media screen and (max-width: 600px) { - display: block; - width: auto; - height: 150px; - margin: 10px auto; - } -`; - -export const Link = styled.a` - text-decoration: none; - color: #49494a; - - &:before { - position: absolute; - z-index: 0; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: block; - content: ''; - } -`; - -export const Name = styled.span` - font-weight: 400; - display: block; - max-width: 80%; - font-size: 16px; - line-height: 18px; -`; - -export const Url = styled.span` - font-size: 11px; - line-height: 16px; - color: #a1a1a4; -`; - -export const Footer = styled.span` - display: block; - position: absolute; - bottom: 0; - left: 0; - right: 0; - width: 100%; - padding: 20px; - border-top: 1px solid #eee; - font-size: 13px; -`; - -export const Label = styled.span` - font-size: 13px; - align-items: center; - font-weight: bold; - display: flex; - position: absolute; - right: 20px; - top: 0; - line-height: 40px; - margin: 0 10px; - - @media screen and (max-width: 200px) { - right: auto; - left: 10px; - } - - color: ${({ status }) => (status && STATUS_COLOR[status]) || '#8dc63f'}; -`; - -export const Dot = styled.span` - display: inline-block; - vertical-align: middle; - width: 16px; - height: 16px; - overflow: hidden; - border-radius: 50%; - padding: 0; - text-indent: -9999px; - color: transparent; - line-height: 16px; - margin-left: 10px; - background: ${({ status }) => (status && STATUS_COLOR[status]) || '#8dc63f'}; -`; diff --git a/src/universal/partials/Welcome/welcomeStyle.js b/src/universal/partials/Welcome/welcomeStyle.js new file mode 100644 index 0000000..9b09e2b --- /dev/null +++ b/src/universal/partials/Welcome/welcomeStyle.js @@ -0,0 +1,322 @@ +const welcomeStyle = () => { + return ``; +}; + +export default welcomeStyle; diff --git a/src/universal/partials/withBaseComponent.js b/src/universal/partials/withBaseComponent.js index 0f78d45..42021e0 100644 --- a/src/universal/partials/withBaseComponent.js +++ b/src/universal/partials/withBaseComponent.js @@ -5,6 +5,7 @@ import ClientApp from '../components/ClientApp'; import { WINDOW_GLOBAL_PARAMS } from '../utils/constants'; import { createComponentName } from '../utils/helper'; import voltranConfig from '../../../voltran.config'; +import HistoryService from '../service/HistoryService'; const getStaticProps = () => { const staticProps = {}; @@ -18,13 +19,18 @@ const getStaticProps = () => { return staticProps; }; -const withBaseComponent = (PageComponent, pathName) => { +const withBaseComponent = (PageComponent, pathName, wrapperEl) => { const componentName = createComponentName(pathName); const prefix = voltranConfig.prefix.toUpperCase(); if (process.env.BROWSER && window[prefix] && window[prefix][componentName.toUpperCase()]) { const fragments = window[prefix][componentName.toUpperCase()]; - const history = window[WINDOW_GLOBAL_PARAMS.HISTORY]; + + window[WINDOW_GLOBAL_PARAMS.VOLTRAN_HISTORY] = + window[WINDOW_GLOBAL_PARAMS.VOLTRAN_HISTORY] || + new HistoryService(WINDOW_GLOBAL_PARAMS.VOLTRAN_HISTORY); + const history = window[WINDOW_GLOBAL_PARAMS.VOLTRAN_HISTORY].getHistory(); + const staticProps = getStaticProps(); Object.keys(fragments).forEach(id => { @@ -34,10 +40,20 @@ const withBaseComponent = (PageComponent, pathName) => { if (isHydrated || !componentEl) return; const initialState = fragments[id].STATE; + const Wrapper = wrapperEl; + const pageComponent = ( + + ); ReactDOM.hydrate( - + {Wrapper ? ( + + {pageComponent} + + ) : ( + pageComponent + )} , componentEl, () => { diff --git a/src/universal/service/CookieService.js b/src/universal/service/CookieService.js new file mode 100644 index 0000000..e62c0ed --- /dev/null +++ b/src/universal/service/CookieService.js @@ -0,0 +1,27 @@ +import Cookies from 'js-cookie'; + +const appConfig = require('__APP_CONFIG__'); + +const domain = appConfig.cookieStorageUrl; + +export default class CookieService { + static setItem(key, value, options) { + Cookies.set(key, value, { domain, ...options }); + } + + static getItem(key) { + return Cookies.get(key); + } + + static getAllItems() { + return Cookies.get(); + } + + static getJSON(key) { + return Cookies.getJSON(key); + } + + static removeItem(key, options) { + Cookies.remove(key, { domain, ...options }); + } +} diff --git a/src/universal/service/HistoryService.js b/src/universal/service/HistoryService.js new file mode 100644 index 0000000..0797cac --- /dev/null +++ b/src/universal/service/HistoryService.js @@ -0,0 +1,60 @@ +import { createBrowserHistory } from 'history'; +import { WINDOW_GLOBAL_PARAMS } from '../utils/constants'; + +let instance; + +const setReferrer = referrer => { + if (process.env.BROWSER) { + window.ref = referrer; + } +}; + +export default class HistoryService { + constructor(historyKey) { + if (instance) { + return instance; + } + + this.current = ''; + this.previous = ''; + this.history = null; + this.historyKey = historyKey; + this._listenCallback = this._listenCallback.bind(this); + + this._selectHistoryKey(); + + if (process.env.BROWSER) { + this.current = window.location.href; + this.history = window[this.historyKey]; + + if (this.historyKey === historyKey) { + this.history.listen(this._listenCallback); + } + } + + instance = this; + } + + _listenCallback(location) { + if (!process.env.BROWSER) return; + + this.previous = this.current; + this.current = `${window.location.origin}${location.pathname}${location.search}${location.hash}`; + setReferrer(this.previous); + } + + _selectHistoryKey() { + if (!process.env.BROWSER) return; + + // eslint-disable-next-line no-prototype-builtins + if (!window.hasOwnProperty(WINDOW_GLOBAL_PARAMS.HISTORY)) { + window[this.historyKey] = createBrowserHistory(); + } else { + this.historyKey = WINDOW_GLOBAL_PARAMS.HISTORY; + } + } + + getHistory() { + return this.history; + } +} diff --git a/src/universal/service/RenderService.js b/src/universal/service/RenderService.js index 33acc48..0cd01b3 100644 --- a/src/universal/service/RenderService.js +++ b/src/universal/service/RenderService.js @@ -2,42 +2,13 @@ import React from 'react'; import ReactDOMServer from 'react-dom/server'; import { ServerStyleSheet } from 'styled-components'; import { StaticRouter } from 'react-router'; + import ConnectedApp from '../components/App'; import Html from '../components/Html'; import PureHtml, { generateLinks, generateScripts } from '../components/PureHtml'; -import ServerApiManagerCache from '../core/api/ServerApiManagerCache'; import createBaseRenderHtmlProps from '../utils/baseRenderHtml'; import { guid } from '../utils/helper'; - -const getStates = async (component, context, predefinedInitialState) => { - const initialState = predefinedInitialState || { data: {} }; - let subComponentFiles = []; - let seoState = {}; - let responseOptions = {}; - - if (context.isWithoutState) { - return { initialState, seoState, subComponentFiles, responseOptions }; - } - - if (!predefinedInitialState && component.getInitialState) { - const services = component.services.map(serviceName => ServerApiManagerCache[serviceName]); - initialState.data = await component.getInitialState(...[...services, context]); - } - - if (component.getSeoState) { - seoState = component.getSeoState(initialState.data); - } - - if (initialState.data.subComponentFiles) { - subComponentFiles = initialState.data.subComponentFiles; - } - - if (initialState.data.responseOptions) { - responseOptions = initialState.data.responseOptions; - } - - return { initialState, seoState, subComponentFiles, responseOptions }; -}; +import getStates from './getStates'; const renderLinksAndScripts = (html, links, scripts) => { return html @@ -45,10 +16,15 @@ const renderLinksAndScripts = (html, links, scripts) => { .replace('
REPLACE_WITH_SCRIPTS
', scripts); }; -const renderHtml = (component, initialState, context) => { +const renderHtml = ({ component, initialState, context, extraPropKeys }) => { // eslint-disable-next-line no-param-reassign component.id = guid(); - const initialStateWithLocation = { ...initialState, location: context, id: component.id }; + const initialStateWithLocation = { + ...initialState, + location: context, + id: component.id, + ...extraPropKeys + }; const sheet = new ServerStyleSheet(); if (isWithoutHTML(context.query)) { @@ -83,15 +59,22 @@ const isWithoutHTML = query => { }; const isPreview = query => { - return query.preview === ''; + if (query.preview || query.preview === '') { + return true; + } + return false; }; const isWithoutState = query => { return query.withoutState === ''; }; +const isRequestDispatcher = query => { + return query.requestDispatcher === '' || query.requestDispatcher !== 'false'; +}; + const renderComponent = async (component, context, predefinedInitialState = null) => { - const { initialState, seoState, subComponentFiles, responseOptions } = await getStates( + const { initialState, subComponentFiles, extraPropKeys, ...restStates } = await getStates( component.object, context, predefinedInitialState @@ -102,7 +85,7 @@ const renderComponent = async (component, context, predefinedInitialState = null subComponentFiles ); - const output = renderHtml(component, initialState, context); + const output = renderHtml({ component, initialState, context, extraPropKeys }); const fullHtml = renderLinksAndScripts(output, generateLinks(links), generateScripts(scripts)); return { @@ -112,12 +95,20 @@ const renderComponent = async (component, context, predefinedInitialState = null scripts, activeComponent, componentName: component.name, - seoState, fullWidth: component.fullWidth, isMobileComponent: component.isMobileComponent, isPreviewQuery: component.isPreviewQuery, - responseOptions, + ...restStates }; }; -export { renderHtml, renderLinksAndScripts, getStates, isWithoutHTML, isPreview, isWithoutState, renderComponent }; +export { + renderHtml, + renderLinksAndScripts, + getStates, + isWithoutHTML, + isPreview, + isRequestDispatcher, + isWithoutState, + renderComponent +}; diff --git a/src/universal/service/getStates.js b/src/universal/service/getStates.js new file mode 100644 index 0000000..7d10d02 --- /dev/null +++ b/src/universal/service/getStates.js @@ -0,0 +1,63 @@ +const getResponseData = (component, context, data) => { + let result = {}; + + if (component?.setResponseData) { + if (typeof component.setResponseData === 'function') { + result = component.setResponseData(context, data); + } else { + result = component.setResponseData; + } + } + + return result; +}; + +const getExtraProps = (component, context, data) => { + let result = {}; + + if (component?.setExtraProps) { + if (typeof component.setExtraProps === 'function') { + result = component.setExtraProps(context, data); + } else { + result = component.setExtraProps; + } + } + + return result; +}; + +const getStates = async (component, context, predefinedInitialState) => { + const initialState = predefinedInitialState || { data: {} }; + let subComponentFiles = []; + let responseOptions = {}; + let responseData = {}; + const extraPropKeys = getExtraProps(component, context, initialState.data); + + if (context.isWithoutState) { + return { initialState, subComponentFiles, responseOptions, extraPropKeys, ...responseData }; + } + + if (!predefinedInitialState && component?.getServerSideProps) { + initialState.data = await component.getServerSideProps(context); + } + + if (initialState?.data?.subComponentFiles) { + subComponentFiles = initialState?.data?.subComponentFiles || []; + } + + if (initialState?.data?.responseOptions) { + responseOptions = initialState?.data?.responseOptions || {}; + } + + responseData = getResponseData(component, context, initialState.data); + + return { + initialState, + subComponentFiles, + responseOptions, + extraPropKeys, + ...responseData + }; +}; + +export default getStates; diff --git a/src/universal/utils/baseRenderHtml.js b/src/universal/utils/baseRenderHtml.js index ee2be34..480ae5b 100644 --- a/src/universal/utils/baseRenderHtml.js +++ b/src/universal/utils/baseRenderHtml.js @@ -41,30 +41,28 @@ if (process.env.NODE_ENV === 'production') { } const getScripts = (name, subComponentFiles) => { - const subComponentFilesScripts = subComponentFiles.scripts; + const subComponentFilesScripts = subComponentFiles?.scripts; const scripts = [ { - src: `${assetsBaseUrl}${assets.client.js}`, + src: `${assetsBaseUrl}${assets?.client?.js}`, isAsync: false }, { - src: `${assetsBaseUrl}${assets[name].js}`, + src: `${assetsBaseUrl}${assets?.[name]?.js}`, isAsync: false } ]; const mergedScripts = - subComponentFilesScripts && subComponentFilesScripts.length > 0 - ? scripts.concat(subComponentFiles.scripts) - : scripts; + subComponentFilesScripts?.length > 0 ? scripts.concat(subComponentFiles.scripts) : scripts; return mergedScripts; }; const getStyles = async (name, subComponentFiles) => { const links = []; - const subComponentFilesStyles = subComponentFiles.styles; + const subComponentFilesStyles = subComponentFiles?.styles; - if (assets[name].css) { + if (assets?.[name]?.css) { links.push({ rel: 'stylesheet', href: `${assetsBaseUrl}${assets[name].css}`, @@ -75,7 +73,7 @@ const getStyles = async (name, subComponentFiles) => { }); } - if (assets.client.css) { + if (assets?.client?.css) { links.push({ rel: 'stylesheet', href: `${assetsBaseUrl}${assets.client.css}`, @@ -87,9 +85,7 @@ const getStyles = async (name, subComponentFiles) => { } const mergedLinks = - subComponentFilesStyles && subComponentFilesStyles.length > 0 - ? links.concat(subComponentFiles.styles) - : links; + subComponentFilesStyles?.length > 0 ? links.concat(subComponentFiles.styles) : links; return mergedLinks; }; diff --git a/src/universal/utils/constants.js b/src/universal/utils/constants.js index 909bda4..c35bdaa 100644 --- a/src/universal/utils/constants.js +++ b/src/universal/utils/constants.js @@ -1,7 +1,8 @@ -const appConfig = require('__APP_CONFIG__'); +const voltranConfig = require('../../../voltran.config'); const WINDOW_GLOBAL_PARAMS = { - HISTORY: 'storefront.pwa.mobile.global.history' + HISTORY: 'storefront.pwa.mobile.global.history', + VOLTRAN_HISTORY: voltranConfig.historyKey || 'voltran.global.history' }; const HTTP_STATUS_CODES = { @@ -17,13 +18,14 @@ const HTTP_STATUS_CODES = { const JSON_CONTENT_TYPE = 'application/json'; const CONTENT_TYPE_HEADER = 'Content-Type'; const REQUEST_TYPES_WITH_BODY = ['post', 'put', 'patch']; -const SERVICES = Object.freeze( - Object.keys(appConfig.services).reduce((obj, val) => { - // eslint-disable-next-line no-param-reassign - obj[val] = Symbol(val); - return obj; - }, {}) -); + +const BLACKLIST_OUTPUT = [ + 'componentName', + 'fullWidth', + 'isMobileComponent', + 'isPreviewQuery', + 'responseOptions' +]; export { HTTP_STATUS_CODES, @@ -31,5 +33,5 @@ export { JSON_CONTENT_TYPE, CONTENT_TYPE_HEADER, REQUEST_TYPES_WITH_BODY, - SERVICES + BLACKLIST_OUTPUT }; diff --git a/src/universal/utils/helper.js b/src/universal/utils/helper.js index 799edaa..c3034b6 100644 --- a/src/universal/utils/helper.js +++ b/src/universal/utils/helper.js @@ -1,4 +1,6 @@ // eslint-disable-next-line import/prefer-default-export +import voltranConfig from '../../../voltran.config'; + export const removeQueryStringFromUrl = url => { const partials = url.split('?'); @@ -9,10 +11,50 @@ export const createComponentName = routePath => { return routePath.split('/').join(''); }; +const toCamel = (value = '') => { + return value + .replace(/([-_][a-z])/gi, key => + key + .toUpperCase() + .replace('-', ' ') + .replace('_', ' ') + ) + .toLowerCase(); +}; + +export const generateComponents = partialsConfig => { + const { paths = {}, components = [], defaultConfig = {}, customConfig = {} } = partialsConfig; + + let result = {}; + Object.entries(paths).forEach(([key, value]) => { + result = { + ...result, + [value]: { + fragment: components[value], + fragmentName: toCamel(key), + name: key, + status: 'dev', + ...defaultConfig, + ...customConfig[value] + } + }; + }); + return result; +}; + export function guid() { return `${s4()}${s4()}-${s4()}-4${s4().substr(0, 3)}-${s4()}-${s4()}${s4()}${s4()}`.toLowerCase(); } +export const getEventBus = () => { + return process.env.BROWSER && window && window.HbEventBus; +}; + +export const getProjectWindowData = () => { + const prefix = voltranConfig.prefix.toUpperCase(); + return process.env.BROWSER && window && window[prefix]; +}; + export function s4() { return Math.floor((1 + Math.random()) * 0x10000) .toString(16) diff --git a/src/universal/utils/logger.js b/src/universal/utils/logger.js index 06b36ef..cd4878f 100644 --- a/src/universal/utils/logger.js +++ b/src/universal/utils/logger.js @@ -2,6 +2,7 @@ const application = 'voltran'; const currentThread = 'event-loop'; const sourceContext = 'app'; +const colors = require('colors'); const logger = { formatter(level, message) { @@ -13,7 +14,7 @@ const logger = { return; } - console.log(this.formatter('INFO', message)); + console.log(colors.blue(this.formatter('INFO', message))); }, error(message) { @@ -24,6 +25,10 @@ const logger = { console.error(this.formatter('ERROR', message)); }, + warning(message) { + console.warn(colors.yellow(message)); + }, + exception(exception, stack = true, requestPath = null) { if (process.env.BROWSER && process.env.VOLTRAN_ENV === 'prod') { return; diff --git a/src/universal/utils/previewHelper.js b/src/universal/utils/previewHelper.js new file mode 100644 index 0000000..3945812 --- /dev/null +++ b/src/universal/utils/previewHelper.js @@ -0,0 +1,26 @@ +import Preview from '../components/Preview'; +import { isPreview } from '../service/RenderService'; + +const previewPages = require('__V_PREVIEW__'); + +const getPreviewLayout = query => { + if (isPreview(query)) { + return query?.preview; + } + + return ''; +}; + +export const getPreviewFile = query => { + const layoutName = getPreviewLayout(query); + const { previewLayouts = {} } = previewPages?.default || {}; + let PreviewFile = Preview; + + if (previewLayouts[layoutName]) { + PreviewFile = previewLayouts[layoutName]; + } else if (previewLayouts.default) { + PreviewFile = previewLayouts.default; + } + + return PreviewFile; +}; diff --git a/src/universal/utils/requestDispatcher.js b/src/universal/utils/requestDispatcher.js new file mode 100644 index 0000000..fba05d7 --- /dev/null +++ b/src/universal/utils/requestDispatcher.js @@ -0,0 +1,19 @@ +import { getEventBus } from './helper'; + +const responsePrefix = 'RequestDispatcher.Response.'; +const requestPrefix = 'RequestDispatcher.'; + +export default { + subscribe(requestName, callback) { + getEventBus().on(`${responsePrefix}${requestName}`, ({ error, body }) => { + if (error) { + callback(error, null); + } else { + callback(null, body); + } + }); + }, + request(requestName, params, options) { + getEventBus().emit(`${requestPrefix}${requestName}`, params, options); + } +}; diff --git a/webpack.client.config.js b/webpack.client.config.js index cb0adfe..15c8840 100644 --- a/webpack.client.config.js +++ b/webpack.client.config.js @@ -1,6 +1,5 @@ const path = require('path'); const fs = require('fs'); - const webpack = require('webpack'); const webpackMerge = require('webpack-merge'); const AssetsPlugin = require('assets-webpack-plugin'); @@ -15,34 +14,31 @@ const { ESBuildMinifyPlugin } = require('esbuild-loader'); require('intersection-observer'); const { createComponentName } = require('./src/universal/utils/helper.js'); - const packageJson = require('./package.json'); +const voltranConfig = require('./voltran.config'); const isBuildingForCDN = process.argv.includes('--for-cdn'); const isAnalyze = process.argv.includes('--analyze'); const env = process.env.VOLTRAN_ENV || 'local'; -const voltranConfig = require('./voltran.config'); const appConfigFilePath = `${voltranConfig.appConfigFile.entry}/${env}.conf.js`; const appConfig = require(appConfigFilePath); const commonConfig = require('./webpack.common.config'); const postCssConfig = require('./postcss.config'); -const babelConfig = require('./babel.server.config'); const voltranClientConfigPath = voltranConfig.webpackConfiguration.client; const voltranClientConfig = voltranClientConfigPath ? require(voltranConfig.webpackConfiguration.client) : ''; -const normalizeUrl = require('./lib/os.js'); const replaceString = require('./config/string.js'); const fragmentManifest = require(voltranConfig.routing.dictionary); +const fixFragmentManifest = require('./src/universal/core/route/dictionary'); const isDebug = voltranConfig.dev; const reScript = /\.(js|jsx|mjs)$/; const distFolderPath = voltranConfig.distFolder; -const prometheusFile = voltranConfig.monitoring.prometheus; const chunks = {}; @@ -53,18 +49,17 @@ chunks.client = [ path.resolve(__dirname, 'src/client/client.js') ]; -for (const index in fragmentManifest) { - if (!fragmentManifest[index]) { - continue; - } - - const fragment = fragmentManifest[index]; - const fragmentUrl = fragment.path; - const name = createComponentName(fragment.routePath); +function generateChunk(fragments) { + fragments.forEach(fragment => { + const fragmentUrl = fragment?.path; + const name = createComponentName(fragment.routePath); - chunks[name] = [fragmentUrl]; + chunks[name] = [fragmentUrl]; + }); } +generateChunk([...fragmentManifest, ...fixFragmentManifest]); + if (isDebug) { chunks.client.push('webpack-hot-middleware/client'); } @@ -136,13 +131,13 @@ const clientConfig = webpackMerge(commonConfig, voltranClientConfig, { use: [ isDebug ? { - loader: 'style-loader', - options: { - insertAt: 'top', - singleton: true, - sourceMap: false + loader: 'style-loader', + options: { + insertAt: 'top', + singleton: true, + sourceMap: false + } } - } : MiniCssExtractPlugin.loader, { loader: 'css-loader', @@ -164,13 +159,13 @@ const clientConfig = webpackMerge(commonConfig, voltranClientConfig, { use: [ isDebug ? { - loader: 'style-loader', - options: { - insertAt: 'top', - singleton: true, - sourceMap: false + loader: 'style-loader', + options: { + insertAt: 'top', + singleton: true, + sourceMap: false + } } - } : MiniCssExtractPlugin.loader, { loader: 'css-loader', @@ -193,14 +188,14 @@ const clientConfig = webpackMerge(commonConfig, voltranClientConfig, { }, ...(voltranConfig.sassResources ? [ - { - loader: 'sass-resources-loader', - options: { - sourceMap: false, - resources: voltranConfig.sassResources + { + loader: 'sass-resources-loader', + options: { + sourceMap: false, + resources: voltranConfig.sassResources + } } - } - ] + ] : []) ] } @@ -226,10 +221,10 @@ const clientConfig = webpackMerge(commonConfig, voltranClientConfig, { ...(isBuildingForCDN ? [] : [ - new CleanWebpackPlugin([distFolderPath], { - verbose: true - }) - ]), + new CleanWebpackPlugin([distFolderPath], { + verbose: true + }) + ]), new webpack.DefinePlugin({ 'process.env.BROWSER': true, @@ -247,11 +242,11 @@ const clientConfig = webpackMerge(commonConfig, voltranClientConfig, { ...(isDebug ? [new webpack.HotModuleReplacementPlugin()] : [ - new MiniCssExtractPlugin({ - filename: '[name].css', - chunkFilename: '[id]-[contenthash].css' - }) - ]), + new MiniCssExtractPlugin({ + filename: '[name].css', + chunkFilename: '[id]-[contenthash].css' + }) + ]), new AssetsPlugin({ path: voltranConfig.inputFolder, diff --git a/webpack.server.config.js b/webpack.server.config.js index 1b9b79b..ba036dc 100644 --- a/webpack.server.config.js +++ b/webpack.server.config.js @@ -25,6 +25,10 @@ const voltranServerConfigPath = voltranConfig.webpackConfiguration.server; const voltranServerConfig = voltranServerConfigPath ? require(voltranConfig.webpackConfiguration.server) : ''; +const voltranCustomServer = + voltranConfig.entry.server && !isDebug ? voltranConfig.entry.server : 'src/server.js'; + +const voltranServer = path.resolve(__dirname, isDebug ? voltranCustomServer : 'src/main.js'); const serverConfig = webpackMerge(commonConfig, voltranServerConfig, { name: 'server', @@ -34,7 +38,7 @@ const serverConfig = webpackMerge(commonConfig, voltranServerConfig, { mode: isDebug ? 'development' : 'production', entry: { - server: [path.resolve(__dirname, isDebug ? 'src/server.js' : 'src/main.js')] + server: [voltranServer] }, output: {