diff --git a/README.md b/README.md index 30a5e1d43..6b6e266eb 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,8 @@ http.createServer(function (req, res) { ``` Open `http://localhost:4000/` and enjoy! +More docs about the node.js client [here](client/README.md). + ## Server-side rendering with Ruby * [Ruby library](https://github.com/opentable/ruby-oc) diff --git a/client/README.md b/client/README.md index 3ae123a01..8bec474d9 100644 --- a/client/README.md +++ b/client/README.md @@ -55,8 +55,8 @@ Options: |`disableFailoverRendering`|`boolean`|no|Disables the automatic failover rendering in case the registry times-out (in case configuration.registries.clientRendering contains a valid value.) Default false| |`headers`|`object`|no|An object containing all the headers that must be forwarded to the component| |`ie8`|`boolean`|no|Default false, if true puts in place the necessary polyfills to make all the stuff work with ie8| -|`params`|`object`|no|An object containing the parameters for component's request| -|`render`|`string`|no|Default `server`. When `client` will produce the html to put in the page for post-poning the rendering to the browser| +|`parameters`|`object`|no|An object containing the parameters for component's request| +|`render`|`string`|no|Default `server`. When `server`, it will return html. When `client` will produce the html to put in the page for post-poning the rendering to the browser| |`timeout`|`number` (seconds)|no|Default 5. When request times-out, the callback will be fired with a timeout error and a client-side rendering response (unless `disableFailoverRendering` is set to `true`)| Example: @@ -67,7 +67,7 @@ client.renderComponent('header', { headers: { 'accept-language': 'en-GB' }, - params: { + parameters: { loggedIn: true }, timeout: 2 @@ -76,3 +76,55 @@ client.renderComponent('header', { // => "
This is the header. Log-out
" }); ``` + +### Client#renderComponents(components [, options], callback) + +It will make a request to the registry, and will render the components. The callback will contain an array of errors (array of `null` in case there aren't any) and an array of rendered html snippets. It will follow the same order of the request. This method will make **1** request to the registry + **n** requests for each component to get the views of components that aren't cached yet. After caching the views, this will make just **1** request to the registry. + +Components parameter: + +|Parameter|type|mandatory|description| +|---------|----|---------|-----------| +|`components`|`array of objects`|yes|The array of components to retrieve and render| +|`components[index].name`|`string`|yes|The component's name| +|`components[index].version`|`string`|no|The component's version. When not speficied, it will use globally specified one (doing client initialisation); when not specified and not globally specified, it will default to "" (latest)| +|`components[index].parameters`|`object`|no|The component's parameters| +|`components[index].render`|`string`|no|The component's render mode. When not specified, it will be the one specified in the options (for all components); if none is specified in options, it will default to `server`. When `server`, the rendering will be performed on the server-side and the result will be component's html. If `client`, the html will contain a promise to do the rendering on the browser.| + +Options: + +|Parameter|type|mandatory|description| +|---------|----|---------|-----------| +|`container`|`boolean`|no|Default true, when false, renders a component without its container| +|`disableFailoverRendering`|`boolean`|no|Disables the automatic failover rendering in case the registry times-out (in case configuration.registries.clientRendering contains a valid value.) Default false| +|`headers`|`object`|no|An object containing all the headers that must be forwarded to the component| +|`ie8`|`boolean`|no|Default false, if true puts in place the necessary polyfills to make all the stuff work with ie8| +|`render`|`string`|no|Default `server`. When `server`, it will return html. When `client` will produce the html to put in the page for post-poning the rendering to the browser| +|`timeout`|`number` (seconds)|no|Default 5. When request times-out, the callback will be fired with a timeout error and a client-side rendering response (unless `disableFailoverRendering` is set to `true`)| + +Example: +```js +... +client.renderComponents([{ + name: 'header', + parameters: { loggedIn: true } +}, { + name: 'footer', + version: '4.5.X' +}, { + name: 'advert', + parameters: { position: 'left' }, + render: 'client' +}], { + container: false, + headers: { + 'accept-language': 'en-US' + }, + timeout: 3.0 +}, function(errors, htmls){ + console.log(html); + // => ["
Header
", + // "

Footer

", + // "<\/oc-component>"] +}); +``` diff --git a/client/src/components-renderer.js b/client/src/components-renderer.js new file mode 100644 index 000000000..9d50c2c1f --- /dev/null +++ b/client/src/components-renderer.js @@ -0,0 +1,226 @@ +'use strict'; + +var Cache = require('nice-cache'); +var format = require('stringformat'); + +var GetCompiledTemplate = require('./get-compiled-template'); +var GetOCClientScript = require('./get-oc-client-script'); +var HrefBuilder = require('./href-builder'); +var htmlRenderer = require('./html-renderer'); +var request = require('./utils/request'); +var sanitiser = require('./sanitiser'); +var settings = require('./settings'); +var templates = require('./templates'); +var _ = require('./utils/helpers'); + +module.exports = function(config, renderTemplate){ + + var cache = new Cache(config.cache), + getOCClientScript = new GetOCClientScript(cache), + getCompiledTemplate = new GetCompiledTemplate(cache), + buildHref = new HrefBuilder(config), + serverRenderingFail = settings.serverSideRenderingFail; + + return function(components, options, callback){ + + options = sanitiser.sanitiseGlobalRenderOptions(options, config); + + var toDo = []; + + _.each(components, function(component, i){ + component.version = component.version || config.components[component.name]; + toDo.push({ + component: component, + pos: i, + render: component.render || options.render || 'server', + result: {} + }); + }); + + var makePostRequest = function(components, cb){ + request({ + url: config.registries.serverRendering, + method: 'post', + headers: options.headers, + timeout: options.timeout, + json: true, + body: { components: components } + }, cb); + }; + + var getComponentsData = function(cb){ + + var serverRendering = { + components: [], + positions: [] + }; + + _.each(toDo, function(action){ + if(action.render === 'server'){ + serverRendering.components.push(action.component); + serverRendering.positions.push(action.pos); + } + }); + + if(_.isEmpty(serverRendering.components)){ + return cb(); + } else if(!config.registries.serverRendering){ + _.each(toDo, function(action){ + action.result.error = serverRenderingFail; + if(!!options.disableFailoverRendering){ + action.result.html = ''; + action.done = true; + } else { + action.render = 'client'; + action.failover = true; + } + }); + + return cb(serverRenderingFail); + } + + makePostRequest(serverRendering.components, function(error, responses){ + if(!!error || !responses || _.isEmpty(responses)){ + responses = []; + _.each(serverRendering.components, function(){ + responses.push({ status: -1 }); + }); + } + + _.each(responses, function(response, i){ + var action = toDo[serverRendering.positions[i]]; + + if(action.render === 'server'){ + if(response.status !== 200){ + action.result.error = serverRenderingFail; + if(!!options.disableFailoverRendering){ + action.result.html = ''; + action.done = true; + } else { + action.render = 'client'; + action.failover = true; + } + } else { + action.apiResponse = response.response; + } + } + }); + cb(); + }); + }; + + var fetchTemplateAndRender = function(component, cb){ + + var data = component.data, + isLocal = component.type === 'oc-component-local', + useCache = !isLocal; + + getCompiledTemplate(component.template, useCache, options.timeout, function(err, template){ + if(!!err){ return cb(err); } + + var renderOptions = { + href: component.href, + key: component.template.key, + version: component.version, + templateType: component.template.type, + container: options.container, + renderInfo: options.renderInfo, + name: component.name + }; + + renderTemplate(template, data, renderOptions, cb); + }); + }; + + var renderComponents = function(cb){ + + var toRender = []; + + _.each(toDo, function(action){ + if(action.render === 'server' && !!action.apiResponse){ + toRender.push(action); + } + }); + + if(_.isEmpty(toRender)){ + return cb(); + } + + _.eachAsync(toRender, function(action, next){ + fetchTemplateAndRender(action.apiResponse, function(err, html){ + if(!!err){ + action.result.error = serverRenderingFail; + if(!!options.disableFailoverRendering){ + action.result.html = ''; + action.done = true; + } else { + action.render = 'client'; + action.failover = true; + } + } else { + action.result.html = html; + action.done = true; + } + next(); + }); + }, cb); + }; + + var processClientReponses = function(cb){ + var toProcess = []; + + _.each(toDo, function(action){ + if(action.render === 'client' && !action.done){ + toProcess.push(action); + } + }); + + if(_.isEmpty(toProcess)){ + return cb(); + } + + getOCClientScript(function(clientErr, clientJs){ + _.each(toDo, function(action){ + if(action.render === 'client'){ + if(!!clientErr || !clientJs){ + action.result.error = settings.genericError; + action.result.html = ''; + } else { + var componentClientHref = buildHref.client(action.component); + + if(!componentClientHref){ + action.result.error = settings.clientSideRenderingFail; + action.result.html = ''; + } else { + var unrenderedComponentTag = htmlRenderer.unrenderedComponent(componentClientHref, options); + + if(action.failover){ + action.result.html = format(templates.clientScript, clientJs, unrenderedComponentTag); + } else { + action.result.error = null; + action.result.html = unrenderedComponentTag; + } + } + } + } + }); + cb(); + }); + }; + + getComponentsData(function(){ + renderComponents(function(){ + processClientReponses(function(){ + var errors = [], + results = []; + + _.each(toDo, function(action){ + errors.push(action.result.error); + results.push(action.result.html); + }); + callback(errors, results); + }); + }); + }); + }; +}; \ No newline at end of file diff --git a/client/src/executor.js b/client/src/executor.js new file mode 100644 index 000000000..dbcca7bbe --- /dev/null +++ b/client/src/executor.js @@ -0,0 +1,11 @@ +'use strict'; + +var vm = require('vm'); + +module.exports = { + template: function(code, template){ + var context = template.type === 'jade' ? { jade: require('jade/runtime.js')} : {}; + vm.runInNewContext(code, context); + return context.oc.components[template.key]; + } +}; \ No newline at end of file diff --git a/client/src/get-compiled-template.js b/client/src/get-compiled-template.js new file mode 100644 index 000000000..3fce7d200 --- /dev/null +++ b/client/src/get-compiled-template.js @@ -0,0 +1,29 @@ +'use strict'; + +var executor = require('./executor'); +var request = require('./utils/request'); +var TryGetCached = require('./try-get-cached'); + +module.exports = function(cache){ + + var tryGetCached = new TryGetCached(cache); + + return function(template, useCache, timeout, callback){ + + var getTemplateFromS3 = function(cb){ + request({ + url: template.src, + timeout: timeout + }, function(err, templateText){ + if(!!err){ return cb(err); } + cb(null, executor.template(templateText, template)); + }); + }; + + if(!!useCache){ + return tryGetCached('template', template.key, getTemplateFromS3, callback); + } + + return getTemplateFromS3(callback); + }; +}; \ No newline at end of file diff --git a/client/src/get-oc-client-script.js b/client/src/get-oc-client-script.js new file mode 100644 index 000000000..71af12c1e --- /dev/null +++ b/client/src/get-oc-client-script.js @@ -0,0 +1,17 @@ +'use strict'; + +var fs = require('fs'); +var path = require('path'); + +var TryGetCached = require('./try-get-cached'); + +module.exports = function(cache){ + + var tryGetCached = new TryGetCached(cache); + + return function(callback){ + tryGetCached('scripts', 'oc-client', function(cb){ + fs.readFile(path.resolve(__dirname, './oc-client.min.js'), 'utf-8', cb); + }, callback); + }; +}; \ No newline at end of file diff --git a/client/src/href-builder.js b/client/src/href-builder.js new file mode 100644 index 000000000..33f3aae47 --- /dev/null +++ b/client/src/href-builder.js @@ -0,0 +1,22 @@ +'use strict'; + +var querystring = require('querystring'); +var url = require('url'); + +module.exports = function(config){ + + return { + client: function(component){ + if(!config.registries.clientRendering){ + return null; + } + + var versionSegment = !!component.version ? ('/' + component.version) : '', + registryUrl = config.registries.clientRendering, + registrySegment = registryUrl.slice(-1) === '/' ? registryUrl : (registryUrl + '/'), + qs = !!component.parameters ? ('/?' + querystring.stringify(component.parameters)) : ''; + + return url.resolve(registrySegment, component.name) + versionSegment + qs; + } + }; +}; \ No newline at end of file diff --git a/client/src/html-renderer.js b/client/src/html-renderer.js new file mode 100644 index 000000000..6c91c094c --- /dev/null +++ b/client/src/html-renderer.js @@ -0,0 +1,28 @@ +'use strict'; + +var format = require('stringformat'); + +var templates = require('./templates'); + +module.exports = { + renderedComponent: function(data){ + + if(!!data.name && data.renderInfo !== false){ + data.html += format(templates.renderInfo, data.name, data.version); + } + + if(data.container !== false){ + data.html = format(templates.componentTag, data.href, data.key, data.name || '', data.version, data.html); + } + + return data.html; + }, + unrenderedComponent: function(href, options){ + if(!href){ return ''; } + + var youCareAboutIe8 = !!options && !!options.ie8, + template = templates['componentUnrenderedTag' + (youCareAboutIe8 ? 'Ie8' : '')]; + + return format(template, href); + } +}; \ No newline at end of file diff --git a/client/src/index.js b/client/src/index.js index 97e2b09f3..5eac6eaa1 100644 --- a/client/src/index.js +++ b/client/src/index.js @@ -1,197 +1,38 @@ 'use strict'; -var Cache = require('nice-cache'); -var format = require('stringformat'); -var fs = require('fs'); -var path = require('path'); -var querystring = require('querystring'); -var url = require('url'); -var vm = require('vm'); - -var Handlebars = require('./renderers/handlebars'); -var Jade = require('./renderers/jade'); - -var request = require('./utils/request'); +var ComponentsRenderer = require('./components-renderer'); var sanitiser = require('./sanitiser'); -var settings = require('./settings'); -var templates = require('./templates'); +var TemplateRenderer = require('./template-renderer'); var validator = require('./validator'); var _ = require('./utils/helpers'); -var isLocal = function(apiResponse){ - return apiResponse.type === 'oc-component-local'; -}; - -var getRenderedComponent = function(data){ - - if(!!data.name && data.renderInfo !== false){ - data.html += format(templates.renderInfo, data.name, data.version); - } - - if(data.container !== false){ - data.html = format(templates.componentTag, data.href, data.key, data.name || '', data.version, data.html); - } - - return data.html; -}; - -var getUnrenderedComponent = function(href, options){ - if(!href){ return ''; } - - var youCareAboutIe8 = !!options && !!options.ie8, - template = templates['componentUnrenderedTag' + (youCareAboutIe8 ? 'Ie8' : '')]; - - return format(template, href); -}; - module.exports = function(conf){ var config = sanitiser.sanitiseConfiguration(conf), validationResult = validator.validateConfiguration(config), - self = this; + renderTemplate = new TemplateRenderer(), + renderComponents = new ComponentsRenderer(config, renderTemplate); if(!validationResult.isValid){ - throw validationResult.error; + throw new Error(validationResult.error); } - this.renderers = { - handlebars: new Handlebars(), - jade: new Jade() - }; - - var cache = new Cache(config.cache); - - var getCompiledTemplate = function(template, tryGetCached, timeout, callback){ - if(!!tryGetCached){ - var compiledTemplate = cache.get('template', template.key); - if(!!compiledTemplate){ - return callback(null, compiledTemplate); - } - } - - request(template.src, {}, timeout, function(err, templateText){ - - if(!!err){ return callback(err); } - - var context = { jade: require('jade/runtime.js')}; - vm.runInNewContext(templateText, context); - var compiledTemplate = context.oc.components[template.key]; - cache.set('template', template.key, compiledTemplate); - return callback(null, compiledTemplate); - }); - }; - - var buildComponentHrefs = function(name, params){ - - var version = config.components[name], - versionSegment = !!version ? (version + '/') : ''; - - var getHref = function(renderMode){ - if(!!config.registries[renderMode]){ - var registryUrl = config.registries[renderMode], - registrySegment = registryUrl.slice(-1) === '/' ? registryUrl : (registryUrl + '/'), - qs = !!params ? ('?' + querystring.stringify(params)) : ''; - - return url.resolve(registrySegment, name + '/') + versionSegment + qs; - } - }; - - return { - clientRendering: getHref('clientRendering'), - serverRendering: getHref('serverRendering') - }; - }; + this.renderTemplate = renderTemplate; this.renderComponent = function(componentName, options, callback){ - if(_.isFunction(options)){ - callback = options; - options = {}; - } - - options = options || {}; - options.headers = options.headers || {}; - options.timeout = options.timeout || 5; - - if(!_.has(config.components, componentName)){ - config.components[componentName] = ''; - } - - self.render(buildComponentHrefs(componentName, options.params), options, callback); - }; - - this.render = function(hrefs, options, callback){ - if(_.isFunction(options)){ - callback = options; - options = {}; - } - - options = options || {}; - options.headers = options.headers || {}; - options.headers.accept = 'application/vnd.oc.unrendered+json'; - options.timeout = options.timeout || 5; - - var processError = function(){ - var errorDescription = settings.messages.serverSideRenderingFail; - - if(!!options.disableFailoverRendering){ - return callback(errorDescription, ''); - } - - var unrenderedComponentTag = getUnrenderedComponent(hrefs.clientRendering, options); - - if(!unrenderedComponentTag){ - return callback(errorDescription); - } - - fs.readFile(path.resolve(__dirname, './oc-client.min.js'), 'utf-8', function(err, clientJs){ - var clientSideHtml = format(templates.clientScript, clientJs, unrenderedComponentTag); - return callback(errorDescription, clientSideHtml); - }); - }; - - if(options.render === 'client'){ - return callback(null, getUnrenderedComponent(hrefs.clientRendering, options)); - } - - request(hrefs.serverRendering, options.headers, options.timeout, function(err, apiResponse){ - - if(err){ - return processError(); - } else { - - try { - apiResponse = JSON.parse(apiResponse); - } catch(e){ - return processError(); - } - - var data = apiResponse.data, - local = isLocal(apiResponse); - - getCompiledTemplate(apiResponse.template, !local, options.timeout, function(err, template){ - - if(!!err){ return processError(); } - - var renderOptions = { - href: hrefs.clientRendering, - key: apiResponse.template.key, - version: apiResponse.version, - templateType: apiResponse.template.type, - container: (options.container === true) ? true : false, - renderInfo: (options.renderInfo === false) ? false : true, - name: apiResponse.name - }; - - return self.renderTemplate(template, data, renderOptions, callback); - }); - } + if(_.isFunction(options)){ callback = options; } + + renderComponents([{ + name: componentName, + version: options.version, + parameters: options.parameters || options.params + }], options, function(errors, results){ + callback(errors[0], results[0]); }); }; - this.renderTemplate = function(template, data, options, callback){ - this.renderers[options.templateType].render(template, data, function(err, html){ - options.html = html; - return callback(err, getRenderedComponent(options)); - }); + this.renderComponents = function(components, options, callback){ + if(_.isFunction(options)){ callback = options; } + renderComponents(components, options, callback); }; }; \ No newline at end of file diff --git a/client/src/sanitiser.js b/client/src/sanitiser.js index 588473edc..5566ab4d5 100644 --- a/client/src/sanitiser.js +++ b/client/src/sanitiser.js @@ -1,5 +1,7 @@ 'use strict'; +var _ = require('./utils/helpers'); + module.exports = { sanitiseConfiguration: function(conf){ conf = conf || {}; @@ -7,5 +9,24 @@ module.exports = { conf.cache = conf.cache || {}; return conf; + }, + sanitiseGlobalRenderOptions: function(options, config){ + + if(_.isFunction(options)){ + options = {}; + } + + options = options || {}; + options.headers = options.headers || {}; + options.headers.accept = 'application/vnd.oc.unrendered+json'; + options.timeout = options.timeout || 5; + options.container = (options.container === true) ? true : false; + options.renderInfo = (options.renderInfo === false) ? false : true; + + if(!!config.registries && !config.registries.clientRendering){ + options.disableFailoverRendering = true; + } + + return options; } }; diff --git a/client/src/settings.js b/client/src/settings.js index e5e4745e4..1b24d89ab 100644 --- a/client/src/settings.js +++ b/client/src/settings.js @@ -1,9 +1,10 @@ 'use strict'; module.exports = { - messages: { - componentMissing: 'Configuration is not valid - Component not found', - registryUrlMissing: 'Configuration is not valid - Registry location not found', - serverSideRenderingFail: 'Server-side rendering failed' - } + clientSideRenderingFail: 'Client-side rendering failed', + configurationNotValid: 'Configuration is not valid: ', + genericError: 'Internal client error', + registriesEmpty: 'registries must contain at least one endpoint', + registriesIsNotObject: 'registries must be an object', + serverSideRenderingFail: 'Server-side rendering failed' }; \ No newline at end of file diff --git a/client/src/template-renderer.js b/client/src/template-renderer.js new file mode 100644 index 000000000..cac915fc8 --- /dev/null +++ b/client/src/template-renderer.js @@ -0,0 +1,20 @@ +'use strict'; + +var Handlebars = require('./renderers/handlebars'); +var htmlRenderer = require('./html-renderer'); +var Jade = require('./renderers/jade'); + +module.exports = function(){ + + var renderers = { + handlebars: new Handlebars(), + jade: new Jade() + }; + + return function(template, data, options, callback){ + renderers[options.templateType].render(template, data, function(err, html){ + options.html = html; + return callback(err, htmlRenderer.renderedComponent(options)); + }); + }; +}; \ No newline at end of file diff --git a/client/src/templates.js b/client/src/templates.js index 8c0f4eb37..19e5f87b4 100644 --- a/client/src/templates.js +++ b/client/src/templates.js @@ -6,7 +6,7 @@ module.exports = { componentTag: '{4}', - componentUnrenderedTag: '', + componentUnrenderedTag: '', componentUnrenderedTagIe8: '