diff --git a/src/dev/jest/config.json b/src/dev/jest/config.json index d874653631eb5..2d291e4d909d6 100644 --- a/src/dev/jest/config.json +++ b/src/dev/jest/config.json @@ -1,7 +1,7 @@ { "rootDir": "../../..", "roots": [ - "/src/ui/public", + "/src/ui", "/src/core_plugins", "/ui_framework/", "/packages" diff --git a/src/ui/ui_render/bootstrap/app_bootstrap.js b/src/ui/ui_render/bootstrap/app_bootstrap.js new file mode 100644 index 0000000000000..c0e797f36fbb9 --- /dev/null +++ b/src/ui/ui_render/bootstrap/app_bootstrap.js @@ -0,0 +1,56 @@ +import _ from 'lodash'; +import Handlebars from 'handlebars'; +import { createHash } from 'crypto'; +import { readFile } from 'fs'; +import { resolve } from 'path'; + +export class AppBootstrap { + constructor({ templateData, translations }) { + this.templateData = templateData; + this.translations = translations; + this._rawTemplate = undefined; + } + + async getJsFile() { + if (!this._rawTemplate) { + this._rawTemplate = await loadRawTemplate(); + } + + Handlebars.registerHelper('i18n', key => _.get(this.translations, key, '')); + const template = Handlebars.compile(this._rawTemplate, { + knownHelpers: { i18n: true }, + knownHelpersOnly: true, + noEscape: true, // this is a js file, so html escaping isn't appropriate + strict: true, + }); + const compiledJsFile = template(this.templateData); + Handlebars.unregisterHelper('i18n'); + + return compiledJsFile; + } + + async getJsFileHash() { + const fileContents = await this.getJsFile(); + const hash = createHash('sha1'); + hash.update(fileContents); + return hash.digest('hex'); + } +} + +function loadRawTemplate() { + const templatePath = resolve(__dirname, 'template.js.hbs'); + return readFileAsync(templatePath); +} + +function readFileAsync(filePath) { + return new Promise((resolve, reject) => { + readFile(filePath, 'utf8', (err, fileContents) => { + if (err) { + reject(err); + return; + } + + resolve(fileContents); + }); + }); +} diff --git a/src/ui/ui_render/bootstrap/app_bootstrap.test.js b/src/ui/ui_render/bootstrap/app_bootstrap.test.js new file mode 100644 index 0000000000000..c30f6bb2808c0 --- /dev/null +++ b/src/ui/ui_render/bootstrap/app_bootstrap.test.js @@ -0,0 +1,138 @@ +import mockFs from 'mock-fs'; +import { resolve } from 'path'; + +const mockTemplate = ` +{{appId}} +{{bundlePath}} +{{i18n 'foo'}} +`; + +const templatePath = resolve(__dirname, 'template.js.hbs'); + +beforeEach(() => { + mockFs({ + [templatePath]: mockTemplate + }); +}); +afterEach(mockFs.restore); + +import { AppBootstrap } from './app_bootstrap'; + +describe('ui_render/AppBootstrap', () => { + describe('getJsFile()', () => { + test('resolves to a string', async () => { + expect.assertions(1); + + const boostrap = new AppBootstrap(mockConfig()); + const contents = await boostrap.getJsFile(); + + expect(typeof contents).toEqual('string'); + }); + + test('interpolates templateData into string template', async () => { + expect.assertions(2); + + const boostrap = new AppBootstrap(mockConfig()); + const contents = await boostrap.getJsFile(); + + expect(contents).toContain('123'); + expect(contents).toContain('/foo/bar'); + }); + + test('supports i18n', async () => { + expect.assertions(1); + + const boostrap = new AppBootstrap(mockConfig()); + const contents = await boostrap.getJsFile(); + + expect(contents).toContain('translated foo'); + }); + }); + + describe('getJsFileHash()', () => { + test('resolves to a 40 character string', async () => { + expect.assertions(2); + + const boostrap = new AppBootstrap(mockConfig()); + const hash = await boostrap.getJsFileHash(); + + expect(typeof hash).toEqual('string'); + expect(hash).toHaveLength(40); + }); + + test('resolves to the same string for multiple calls with the same config on the same bootstrap object', async () => { + expect.assertions(1); + + const boostrap = new AppBootstrap(mockConfig()); + const hash1 = await boostrap.getJsFileHash(); + const hash2 = await boostrap.getJsFileHash(); + + expect(hash2).toEqual(hash1); + }); + + test('resolves to the same string for multiple calls with the same config on different bootstrap objects', async () => { + expect.assertions(1); + + const boostrap1 = new AppBootstrap(mockConfig()); + const hash1 = await boostrap1.getJsFileHash(); + + const bootstrap2 = new AppBootstrap(mockConfig()); + const hash2 = await bootstrap2.getJsFileHash(); + + expect(hash2).toEqual(hash1); + }); + + test('resolves to different 40 character string with different templateData', async () => { + expect.assertions(3); + + const boostrap1 = new AppBootstrap(mockConfig()); + const hash1 = await boostrap1.getJsFileHash(); + + const config2 = { + ...mockConfig(), + templateData: { + appId: 'not123', + bundlePath: 'not/foo/bar' + } + }; + const bootstrap2 = new AppBootstrap(config2); + const hash2 = await bootstrap2.getJsFileHash(); + + expect(typeof hash2).toEqual('string'); + expect(hash2).toHaveLength(40); + expect(hash2).not.toEqual(hash1); + }); + + test('resolves to different 40 character string with different translations', async () => { + expect.assertions(3); + + const boostrap1 = new AppBootstrap(mockConfig()); + const hash1 = await boostrap1.getJsFileHash(); + + const config2 = { + ...mockConfig(), + translations: { + foo: 'not translated foo' + } + }; + const bootstrap2 = new AppBootstrap(config2); + const hash2 = await bootstrap2.getJsFileHash(); + + expect(typeof hash2).toEqual('string'); + expect(hash2).toHaveLength(40); + expect(hash2).not.toEqual(hash1); + }); + }); +}); + +function mockConfig() { + return { + translations: { + foo: 'translated foo' + }, + templateData: { + appId: 123, + bundlePath: '/foo/bar' + } + }; +} diff --git a/src/ui/ui_render/bootstrap/index.js b/src/ui/ui_render/bootstrap/index.js new file mode 100644 index 0000000000000..e13babaacc448 --- /dev/null +++ b/src/ui/ui_render/bootstrap/index.js @@ -0,0 +1 @@ +export { AppBootstrap } from './app_bootstrap'; diff --git a/src/ui/ui_render/bootstrap/template.js.hbs b/src/ui/ui_render/bootstrap/template.js.hbs new file mode 100644 index 0000000000000..a8577bfb5ce4a --- /dev/null +++ b/src/ui/ui_render/bootstrap/template.js.hbs @@ -0,0 +1,52 @@ +window.onload = function () { + function bundleFile(filename) { + var anchor = document.createElement('a'); + anchor.setAttribute('href', '{{bundlePath}}/' + filename); + return anchor.href; + } + var files = [ + bundleFile('vendors.style.css'), + bundleFile('commons.style.css'), + bundleFile('{{appId}}.style.css'), + + bundleFile('vendors.bundle.js'), + bundleFile('commons.bundle.js'), + bundleFile('{{appId}}.bundle.js') + ]; + + (function next() { + var file = files.shift(); + if (!file) return; + + var failure = function () { + // make subsequent calls to failure() noop + failure = function () {}; + + var err = document.createElement('h1'); + err.style['color'] = 'white'; + err.style['font-family'] = 'monospace'; + err.style['text-align'] = 'center'; + err.style['background'] = '#F44336'; + err.style['padding'] = '25px'; + err.innerText = '{{i18n 'UI-WELCOME_ERROR'}}'; + + document.body.innerHTML = err.outerHTML; + } + + var type = /\.js(\?.+)?$/.test(file) ? 'script' : 'link'; + var dom = document.createElement(type); + dom.setAttribute('async', ''); + dom.addEventListener('error', failure); + + if (type === 'script') { + dom.setAttribute('src', file); + dom.addEventListener('load', next); + document.head.appendChild(dom); + } else { + dom.setAttribute('rel', 'stylesheet'); + dom.setAttribute('href', file); + document.head.appendChild(dom); + next(); + } + }()); +}; diff --git a/src/ui/ui_render/ui_render_mixin.js b/src/ui/ui_render/ui_render_mixin.js index 42c0c066fc747..b353ac8975932 100644 --- a/src/ui/ui_render/ui_render_mixin.js +++ b/src/ui/ui_render/ui_render_mixin.js @@ -2,6 +2,7 @@ import { defaults, get } from 'lodash'; import { props, reduce as reduceAsync } from 'bluebird'; import Boom from 'boom'; import { resolve } from 'path'; +import { AppBootstrap } from './bootstrap'; export function uiRenderMixin(kbnServer, server, config) { @@ -30,6 +31,39 @@ export function uiRenderMixin(kbnServer, server, config) { // render all views from ./views server.setupViews(resolve(__dirname, 'views')); + server.route({ + path: '/bundles/app/{id}/bootstrap.js', + method: 'GET', + config: { auth: false }, + async handler(request, reply) { + try { + const { id } = request.params; + const app = server.getUiAppById(id) || server.getHiddenUiAppById(id); + if (!app) { + throw Boom.notFound(`Unknown app: ${id}`); + } + + const bootstrap = new AppBootstrap({ + templateData: { + appId: app.getId(), + bundlePath: `${config.get('server.basePath')}/bundles` + }, + translations: await request.getUiTranslations() + }); + + const body = await bootstrap.getJsFile(); + const etag = await bootstrap.getJsFileHash(); + + reply(body) + .header('cache-control', 'must-revalidate') + .header('content-type', 'application/javascript') + .etag(etag); + } catch (err) { + reply(err); + } + } + }); + server.route({ path: '/app/{id}', method: 'GET', diff --git a/src/ui/ui_render/views/ui_app.jade b/src/ui/ui_render/views/ui_app.jade index 120f26ad9046a..38942e7bf1ee3 100644 --- a/src/ui/ui_render/views/ui_app.jade +++ b/src/ui/ui_render/views/ui_app.jade @@ -110,58 +110,4 @@ block content .kibanaWelcomeText | #{i18n('UI-WELCOME_MESSAGE')} - script. - window.onload = function () { - var buildNum = #{kibanaPayload.buildNum}; - var cacheParam = buildNum ? '?v=' + buildNum : ''; - function bundleFile(filename) { - var anchor = document.createElement('a'); - anchor.setAttribute('href', !{JSON.stringify(bundlePath)} + '/' + filename + cacheParam); - return anchor.href; - } - var files = [ - bundleFile('vendors.style.css'), - bundleFile('commons.style.css'), - bundleFile('#{app.getId()}.style.css'), - - bundleFile('vendors.bundle.js'), - bundleFile('commons.bundle.js'), - bundleFile('#{app.getId()}.bundle.js') - ]; - - (function next() { - var file = files.shift(); - if (!file) return; - - var failure = function () { - // make subsequent calls to failure() noop - failure = function () {}; - - var err = document.createElement('h1'); - err.style['color'] = 'white'; - err.style['font-family'] = 'monospace'; - err.style['text-align'] = 'center'; - err.style['background'] = '#F44336'; - err.style['padding'] = '25px'; - err.innerText = '#{i18n('UI-WELCOME_ERROR')}'; - - document.body.innerHTML = err.outerHTML; - } - - var type = /\.js(\?.+)?$/.test(file) ? 'script' : 'link'; - var dom = document.createElement(type); - dom.setAttribute('async', ''); - dom.addEventListener('error', failure); - - if (type === 'script') { - dom.setAttribute('src', file); - dom.addEventListener('load', next); - document.head.appendChild(dom); - } else { - dom.setAttribute('rel', 'stylesheet'); - dom.setAttribute('href', file); - document.head.appendChild(dom); - next(); - } - }()); - }; + script(src='#{bundlePath}/app/#{app.getId()}/bootstrap.js')