From 22d263c6d2211a663cbc1634b49093d5bb782221 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 13 Dec 2018 20:41:40 +0000 Subject: [PATCH] Embed Optimizations (#2121) * feat: refactored bridge - Cleaned up implementation of bridge - Removed unneeded babel-polyfill and webpack globals * fix: fixed up some static headers - bumped version * fix: moved intersection observer out of chunk * fix: fixed misplaced "!" --- client/coral-embed/src/Bridge.js | 324 ++++++++++++++++++ .../src/{Snackbar.js => SnackBar.js} | 0 client/coral-embed/src/Stream.js | 259 -------------- client/coral-embed/src/StreamInterface.js | 33 -- client/coral-embed/src/index.js | 146 +++----- client/coral-embed/src/onIntersect.js | 30 +- client/coral-embed/src/wrapBridge.js | 31 ++ config.js | 8 + middleware/staticFiles.js | 44 +++ package.json | 4 +- routes/index.js | 50 +-- webpack.config.js | 40 ++- yarn.lock | 10 +- 13 files changed, 525 insertions(+), 454 deletions(-) create mode 100644 client/coral-embed/src/Bridge.js rename client/coral-embed/src/{Snackbar.js => SnackBar.js} (100%) delete mode 100644 client/coral-embed/src/Stream.js delete mode 100644 client/coral-embed/src/StreamInterface.js create mode 100644 client/coral-embed/src/wrapBridge.js create mode 100644 middleware/staticFiles.js diff --git a/client/coral-embed/src/Bridge.js b/client/coral-embed/src/Bridge.js new file mode 100644 index 000000000..264069adb --- /dev/null +++ b/client/coral-embed/src/Bridge.js @@ -0,0 +1,324 @@ +import queryString from 'querystringify'; +import URLSearchParams from '@ungap/url-search-params'; +import pym from 'pym.js'; +import EventEmitter from 'eventemitter2'; +import { buildUrl } from 'coral-framework/utils/url'; +import SnackBar from './SnackBar'; +import onIntersect from './onIntersect'; +import { + createStorage, + connectStorageToPym, +} from 'coral-framework/services/storage'; + +// Rebuild the origin if it isn't defined. This is our poor-mans polyfill +// for the location APIs. +if (!window.location.origin) { + window.location.origin = `${window.location.protocol}//${ + window.location.hostname + }${window.location.port ? `:${window.location.port}` : ''}`; +} + +const NOTIFICATION_OFFSET = 200; + +// Ensure there is a trailing slash. +function ensureEndSlash(p) { + return p.match(/\/$/) ? p : `${p}/`; +} + +// Build the URL to load in the pym iframe. +function buildStreamIframeUrl(talkBaseUrl, query) { + let url = talkBaseUrl + 'embed/stream?'; + + url += queryString.stringify(query); + + return url; +} + +// detectAssetURL will try to grab the canonical url from the head, if it isn't +// available, source the url from the current one. +function detectAssetURL() { + try { + // Try to get the url from the canonical tag on the page. + return document.querySelector('link[rel="canonical"]').href; + } catch (e) { + window.console.warn( + 'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.' + ); + + return window.location.origin + window.location.pathname; + } +} + +function buildQuery({ asset_id, asset_url }) { + // Compose the query to send down to the Talk API so it knows what to load. + const query = {}; + + // Parse the url parameters to extract some of the information. + const search = new URLSearchParams(window.location.search); + + // Pull the Comment ID out of the query string. + const commentID = search.get('commentId') || search.get('commentID'); + if (commentID) { + query.comment_id = commentID; + } + + // Insert the asset_id into the query. + if (asset_id) { + query.asset_id = asset_id; + } + + // If the asset_url is defined, use it, otherwise, detect it. + if (asset_url) { + query.asset_url = asset_url; + } else { + query.asset_url = detectAssetURL(); + } + + return query; +} + +// Get dimensions of viewport. +function viewportDimensions() { + let target = window; + let prefix = 'inner'; + if (!('innerWidth' in window)) { + prefix = 'client'; + target = document.documentElement || document.body; + } + + return { + width: target[`${prefix}Width`], + height: target[`${prefix}Height`], + }; +} + +export default class Bridge { + constructor( + element, + { + // Pull out the URLs used to setup Talk. + talk: talkBaseUrl, + talkStaticUrl = talkBaseUrl, + // Default the following to null. + events = null, + snackBarStyles = null, + onAuthChanged = null, + // Determine if we're in lazy mode or not. By default, the build argument + // will determine the lazy render status. This default is primarily used + // when the embed code cannot be changed, but control of the Talk serving + // domain is available. + lazy = process.env.TALK_DEFAULT_LAZY_RENDER === 'TRUE', + // Any additional options are extracted to be sent to the embed via the + // pym bridge. + ...opts + } + ) { + this.pym = null; + this.element = element; + this.opts = opts; + this.query = buildQuery(this.opts); + this.emitter = new EventEmitter({ wildcard: true }); + this.snackBar = new SnackBar(snackBarStyles || {}); + this.onAuthChanged = onAuthChanged; + this.talkBaseUrl = ensureEndSlash(talkBaseUrl); + this.talkStaticUrl = ensureEndSlash(talkStaticUrl); + this.lazy = lazy; + + // Store queued operations in a queue that can be processed once the stream + // is rendered. + this.queued = []; + + // Attach to the events emitted by the pym parent. + if (events) { + events(this.emitter); + } + + // Start the embed loading process. + if (this.lazy) { + // When the dom element containing the talk embed container is in view, + // render the stream with force turned on so that it skips this portion. + onIntersect(this.element, () => this.load()); + } else { + // We aren't being lazy, load it now! + this.load(); + } + } + + ensureRendered() { + // Check to see if the pym bridge is created, and the embed is loaded. + if (this.pym === null) { + throw new Error('Stream Embed must be rendered first'); + } + } + + queueWhenRendered(callback) { + // Check to see if the queue is alive, if it isn't, run the callback now, + // otherwise, push the callback to be processed when the stream has loaded. + if (this.queued !== null) { + this.queued.push(callback); + } else { + callback(); + } + } + + setupPym() { + const url = buildStreamIframeUrl(this.talkBaseUrl, this.query); + this.pym = new pym.Parent(this.element.id, url, { + title: this.opts.title, + id: `${this.element.id}_iframe`, + name: `${this.element.id}_iframe`, + }); + + // NOTE: Workaround for iOS Safari which ignores `width` but respects `min-width` value. + this.pym.el.firstChild.style.width = '1px'; + this.pym.el.firstChild.style.minWidth = '100%'; + + // Resize parent iframe height when child height changes + let cachedHeight; + this.pym.onMessage('height', height => { + if (height !== cachedHeight) { + this.pym.el.firstChild.style.height = `${height}px`; + cachedHeight = height; + } + }); + + // Send the config back over the pym bridge if requested. + this.pym.onMessage('getConfig', () => { + this.pym.sendMessage('config', JSON.stringify(this.opts)); + }); + + // If the auth changes, and someone is listening for it, then re-emit it. + if (this.onAuthChanged) { + this.pym.onMessage('coral-auth-changed', message => { + this.onAuthChanged(message ? JSON.parse(message) : null); + }); + } + + // Remove the permalink comment id from the search. + this.pym.onMessage('coral-view-all-comments', () => { + const query = queryString.parse(location.search); + + // Remove the commentId/commentID url param. + delete query.commentId; + delete query.commentID; + + // Rebuild the search field without the commentId in it. + const search = queryString.stringify(query); + + // Change the url. + window.history.replaceState( + {}, + document.title, + buildUrl({ ...location, search }) + ); + }); + + // Remove the permalink comment id from the hash. + this.pym.onMessage('coral-view-comment', id => { + const search = queryString.stringify({ + ...queryString.parse(location.search), + commentId: id, + }); + + // Change the url to the permalink url. + window.history.replaceState( + {}, + document.title, + buildUrl({ ...location, search }) + ); + }); + + // Helps child show notifications at the right scrollTop. + this.pym.onMessage('getPosition', () => { + const { height } = viewportDimensions(); + let position = height + document.body.scrollTop; + + if (position > NOTIFICATION_OFFSET) { + position = position - NOTIFICATION_OFFSET; + } + + this.pym.sendMessage('position', position); + }); + + // When end-user clicks link in iframe, open it in parent context + this.pym.onMessage('navigate', url => { + // Open the new window, detach the opener, and focus on it. + const w = window.open(url, '_blank'); + w.opener = null; + w.focus(); + }); + + // Pass events from iframe to the event emitter. + this.pym.onMessage('event', raw => { + const { eventName, value } = JSON.parse(raw); + this.emitter.emit(eventName, value); + }); + } + + /** + * load will configure the pym parent if it hasn't already been, and setup the + * snackBar. If there are any queued operations, it will run them first. + */ + load() { + if (this.pym !== null) { + throw new Error('Stream Embed already rendered'); + } + + // Setup Pym. + this.setupPym(); + + // Attach the snackBar to the pym parent and to the body of the page. + this.snackBar.attach(window.document.body, this.pym); + + // If the user clicks outside the embed, then tell the embed. + document.addEventListener('click', this.handleClick.bind(this), true); + + // Listens to ${name}Storage requests on pym and relay it to + // ${name}Storage. + ['local', 'session'].forEach(name => { + connectStorageToPym( + createStorage(`${name}Storage`), + this.pym, + `${name}Storage` + ); + }); + + // Process any queued operations. + const queued = this.queued; + this.queued = null; + queued.forEach(callback => callback()); + } + + handleClick() { + this.pym.sendMessage('click'); + } + + enablePluginsDebug() { + this.pym.sendMessage('enablePluginsDebug'); + } + + disablePluginsDebug() { + this.pym.sendMessage('disablePluginsDebug'); + } + + login(token) { + this.pym.sendMessage('login', token); + } + + logout() { + this.pym.sendMessage('logout'); + } + + remove() { + // Remove the event listeners. + document.removeEventListener('click', this.handleClick.bind(this)); + this.emitter.removeAllListeners(); + + // Remove the snackbar. + this.snackBar.remove(); + + // Remove the pym parent. + this.pym.remove(); + this.pym = null; + } +} diff --git a/client/coral-embed/src/Snackbar.js b/client/coral-embed/src/SnackBar.js similarity index 100% rename from client/coral-embed/src/Snackbar.js rename to client/coral-embed/src/SnackBar.js diff --git a/client/coral-embed/src/Stream.js b/client/coral-embed/src/Stream.js deleted file mode 100644 index efb44bc3b..000000000 --- a/client/coral-embed/src/Stream.js +++ /dev/null @@ -1,259 +0,0 @@ -/* global __webpack_public_path__ */ // eslint-disable-line no-unused-vars - -import queryString from 'querystringify'; -import pym from 'pym.js'; -import EventEmitter from 'eventemitter2'; -import { buildUrl } from 'coral-framework/utils/url'; -import Snackbar from './Snackbar'; -import onIntersect from './onIntersect'; -import { - createStorage, - connectStorageToPym, -} from 'coral-framework/services/storage'; - -const NOTIFICATION_OFFSET = 200; - -// Ensure there is a trailing slash. -function ensureEndSlash(p) { - return p.match(/\/$/) ? p : `${p}/`; -} - -// Build the URL to load in the pym iframe. -function buildStreamIframeUrl(talkBaseUrl, query) { - let url = talkBaseUrl + 'embed/stream?'; - - url += queryString.stringify(query); - - return url; -} - -// Get dimensions of viewport. -function viewportDimensions() { - let e = window, - a = 'inner'; - if (!('innerWidth' in window)) { - a = 'client'; - e = document.documentElement || document.body; - } - - return { - width: e[`${a}Width`], - height: e[`${a}Height`], - }; -} - -export default class Stream { - constructor(el, talkBaseUrl, query, config) { - this.query = query; - - // Extract the non-opts opts from the object. - const { - events = null, - snackBarStyles = null, - onAuthChanged = null, - talkStaticUrl = talkBaseUrl, - ...opts - } = config; - - this.onAuthChanged = onAuthChanged; - this.el = el; - this.talkBaseUrl = ensureEndSlash(talkBaseUrl); - this.talkStaticUrl = ensureEndSlash(talkStaticUrl); - this.opts = opts; - this.snackBar = new Snackbar(snackBarStyles || {}); - this.emitter = new EventEmitter({ wildcard: true }); - - // Because we're loading chunks dynamically below, we need to point to the - // static URL. - // - // The __webpack_public_path__ can be referenced: - // https://webpack.js.org/configuration/output/#output-publicpath - // - __webpack_public_path__ = this.talkStaticUrl + 'static/'; - - // Attach to the events emitted by the pym parent. - if (events) { - events(this.emitter); - } - if (config.lazy || process.env.TALK_DEFAULT_LAZY_RENDER === 'TRUE') { - const renderOnIntersect = () => onIntersect(this.el, () => this.render()); - if (!window.IntersectionObserver) { - // Include a polyfill for the intersection observer. - import(/* webpackChunkName: "intersection-observer" */ 'intersection-observer') - .then(() => { - // Polyfill applied. - renderOnIntersect(); - }) - .catch(e => { - console.error(e); - // Loading polyfill failed, just render it directly. - this.render(); - }); - } else { - // No need for polyfill. - renderOnIntersect(); - } - } else { - this.render(); - } - } - - assertRendered() { - if (!this.pym) { - throw new Error('Stream Embed must be rendered first'); - } - } - - isRendered() { - return !!this.pym; - } - - render() { - if (this.pym) { - throw new Error('Stream Embed already rendered'); - } - this.pym = new pym.Parent( - this.el.id, - buildStreamIframeUrl(this.talkBaseUrl, this.query), - { - title: this.opts.title, - id: `${this.el.id}_iframe`, - name: `${this.el.id}_iframe`, - } - ); - - // Workaround: IOS Safari ignores `width` but respects `min-width` value. - this.pym.el.firstChild.style.width = '1px'; - this.pym.el.firstChild.style.minWidth = '100%'; - - // Resize parent iframe height when child height changes - let cachedHeight; - this.pym.onMessage('height', height => { - if (height !== cachedHeight) { - this.pym.el.firstChild.style.height = `${height}px`; - cachedHeight = height; - } - }); - - this.pym.onMessage('getConfig', () => { - this.pym.sendMessage('config', JSON.stringify(this.opts)); - }); - - // If the auth changes, and someone is listening for it, then re-emit it. - if (this.onAuthChanged) { - this.pym.onMessage('coral-auth-changed', message => { - this.onAuthChanged(message ? JSON.parse(message) : null); - }); - } - - // Attach the snackbar to the pym parent and to the body of the page. - this.snackBar.attach(window.document.body, this.pym); - - // Remove the permalink comment id from the hash. - this.pym.onMessage('coral-view-all-comments', () => { - const query = queryString.parse(location.search); - - // Remove the commentId url param. - delete query.commentId; - - const search = queryString.stringify(query); - - const url = buildUrl({ ...location, search }); - - // Change the url. - window.history.replaceState({}, document.title, url); - }); - - // Remove the permalink comment id from the hash. - this.pym.onMessage('coral-view-comment', id => { - const search = queryString.stringify({ - ...queryString.parse(location.search), - commentId: id, - }); - - // Remove the commentId url param. - const url = buildUrl({ ...location, search }); - - // Change the url. - window.history.replaceState({}, document.title, url); - }); - - // Helps child show notifications at the right scrollTop. - this.pym.onMessage('getPosition', () => { - const { height } = viewportDimensions(); - let position = height + document.body.scrollTop; - - if (position > NOTIFICATION_OFFSET) { - position = position - NOTIFICATION_OFFSET; - } - - this.pym.sendMessage('position', position); - }); - - // When end-user clicks link in iframe, open it in parent context - this.pym.onMessage('navigate', url => { - window.open(url, '_blank').focus(); - }); - - // Pass events from iframe to the event emitter. - this.pym.onMessage('event', raw => { - const { eventName, value } = JSON.parse(raw); - this.emitter.emit(eventName, value); - }); - - // If the user clicks outside the embed, then tell the embed. - document.addEventListener('click', this.handleClick.bind(this), true); - - // Listens to local storage requests on pym and relay it to local storage. - connectStorageToPym( - createStorage('localStorage'), - this.pym, - 'localStorage' - ); - - // Listens to session storage requests on pym and relay it to session storage. - connectStorageToPym( - createStorage('sessionStorage'), - this.pym, - 'sessionStorage' - ); - } - - enablePluginsDebug() { - this.assertRendered(); - this.pym.sendMessage('enablePluginsDebug'); - } - - disablePluginsDebug() { - this.assertRendered(); - this.pym.sendMessage('disablePluginsDebug'); - } - - login(token) { - this.assertRendered(); - this.pym.sendMessage('login', token); - } - - logout() { - this.assertRendered(); - this.pym.sendMessage('logout'); - } - - remove() { - this.assertRendered(); - // Remove the event listeners. - document.removeEventListener('click', this.handleClick.bind(this)); - this.emitter.removeAllListeners(); - - // Remove the snackbar. - this.snackBar.remove(); - - // Remove the pym parent. - this.pym.remove(); - } - - handleClick() { - this.assertRendered(); - this.pym.sendMessage('click'); - } -} diff --git a/client/coral-embed/src/StreamInterface.js b/client/coral-embed/src/StreamInterface.js deleted file mode 100644 index 1e54dd155..000000000 --- a/client/coral-embed/src/StreamInterface.js +++ /dev/null @@ -1,33 +0,0 @@ -export default class StreamInterface { - constructor(stream) { - this._stream = stream; - } - - on(eventName, callback) { - return this._stream.emitter.on(eventName, callback); - } - - off(eventName, callback) { - return this._stream.emitter.off(eventName, callback); - } - - login(token) { - return this._stream.login(token); - } - - logout() { - return this._stream.logout(); - } - - remove() { - return this._stream.remove(); - } - - enablePluginsDebug() { - return this._stream.enablePluginsDebug(); - } - - disablePluginsDebug() { - return this._stream.disablePluginsDebug(); - } -} diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index 17afd4c92..e64868e15 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -1,107 +1,71 @@ -import URLSearchParams from 'url-search-params'; -import Stream from './Stream'; -import StreamInterface from './StreamInterface'; +// Polyfill IntersectionObserver always, the alternative is that we have to also +// polyfill for Promise, which itself adds 1KB gziped, which means that the +// 4KB that the intersection observer really doesn't take up that much in terms +// of size. +import 'intersection-observer'; -// Rebuild the origin if it isn't defined. This is our poor-mans polyfill -// for the location API's. -if (!window.location.origin) { - window.location.origin = `${window.location.protocol}//${ - window.location.hostname - }${window.location.port ? `:${window.location.port}` : ''}`; -} +import Bridge from './Bridge'; +import wrapBridge from './wrapBridge'; -// parses the Asset URL from the config variable -function parseAssetURL() { - try { - // Try to get the url from the canonical tag on the page. - return document.querySelector('link[rel="canonical"]').href; - } catch (e) { - window.console.warn( - 'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.' +/** + * validateElement will throw an error when the element is not configured + * correctly or is not an HTMLElement at all. + * + * @param {HTMLElement} element the HTMLElement where the stream will be rendered + */ +function validateElement(element) { + if (!element) { + throw new Error( + 'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.' ); + } - return window.location.origin + window.location.pathname; + if (typeof element !== 'object') { + throw new Error( + `Coral.Talk.render() expected HTMLElement but got ${element} (${typeof element})` + ); + } + + // Ensure element has an id, as pym can't directly accept the HTMLElement. + if (!element.id) { + element.id = `_${Math.random()}`; } } -export class Talk { +/** + * validateConfig is the configuration validation tool. + * + * @param {Object} config the configuration that will be used to setup Talk. + */ +function validateConfig(config) { + if (!config || typeof config !== 'object' || !config.talk) { + throw new Error( + 'Coral.Talk.render() expected configuration with at least opts.talk as the Talk Base URL, none found' + ); + } +} + +export const Talk = { /** * Render a Talk stream * @param {HTMLElement} element - Element to render the stream in * @param {Object} config - Configuration options for talk - * @param {String} config.talk - Talk base URL - * @param {String} [config.title] - Title of Stream (rendered in iframe) - * @param {String} [config.asset_url] - Asset URL - * @param {String} [config.asset_id] - Asset ID + * @param {String} config.talk - URL to the Talk installation + * @param {String} [config.asset_id] - (optional) ID for the Asset + * @param {String} [config.asset_url] - (optional) URL where the Asset is located * @param {String} [config.auth_token] - (optional) A jwt representing the session - * @param {String} [config.lazy] - (optional) If set the stream will only render lazily. + * @param {String} [config.lazy] - (optional) If set the stream will only render lazily + * @param {String} [config.talkStaticUrl] - (optional) Static URL used to serve Talk * @return {Object} - * - * Example: - * ``` - * const embed = Talk.render(document.getElementById('talkStreamEmbed'), config); - * - * // trigger a login with optional token. - * embed.login(token); - * - * // trigger a logout. - * embed.logout(); - * - * // listen to events (in this case all events). - * embed.on('**', function(value) { - * console.log(this.event, value); - * }); - * ``` */ - static render(element, config) { - if (!element) { - throw new Error( - 'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.' - ); - } - if (typeof element !== 'object') { - throw new Error( - `Coral.Talk.render() expected HTMLElement but got ${element} (${typeof element})` - ); - } - if (!config || typeof config !== 'object' || !config.talk) { - throw new Error( - 'Coral.Talk.render() expected configuration with at least opts.talk as the Talk Base URL, none found' - ); - } + render: (element, config) => { + // Validate the element. + validateElement(element); - // Ensure el has an id, as pym can't directly accept the HTMLElement. - if (!element.id) { - element.id = `_${Math.random()}`; - } + // Validate the configuration. + validateConfig(config); - // Compose the query to send down to the Talk API so it knows what to load. - const query = {}; - - // Parse the url parameters to extract some of the information. - const search = new URLSearchParams(window.location.search); - - // Pull the commentID out from the query params. - const commentID = search.get('commentId') || search.get('commentID'); - if (commentID) { - query.comment_id = commentID; - } - - // Extract the asset id from the options. - if (config.asset_id) { - query.asset_id = config.asset_id; - } - - // Parse the Asset URL. - query.asset_url = config.asset_url; - if (!query.asset_url) { - query.asset_url = parseAssetURL(); - } - - // Create the new Stream. - const stream = new Stream(element, config.talk, query, config); - - // Return the public interface for the stream. - return new StreamInterface(stream); - } -} + // Create the new Bridge, and wrap it up. + return wrapBridge(new Bridge(element, config)); + }, +}; diff --git a/client/coral-embed/src/onIntersect.js b/client/coral-embed/src/onIntersect.js index 9814c178c..50716d62a 100644 --- a/client/coral-embed/src/onIntersect.js +++ b/client/coral-embed/src/onIntersect.js @@ -1,20 +1,30 @@ export default function onIntersect(el, callback) { + // Ensure that the intersection observer is available. if (!IntersectionObserver) { // tslint:disable-next-line:no-console - console.warn('IntersectionObserver not available'); + window.console.warn('IntersectionObserver not available, rendering now'); callback(); return; } - const options = { - rootMargin: '100px', - threshold: 1.0, - }; - const observer = new IntersectionObserver(entries => { - if (entries[0].isIntersecting) { - observer.disconnect(); - callback(); + // Create the Intersection Observer that will wait till the embed is within + // view and will then call the callback. + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting) { + // Stop receiving intersection events. + observer.disconnect(); + + // Fire the callback. + callback(); + } + }, + { + rootMargin: '100px', + threshold: 1.0, } - }, options); + ); + + // Start observing the element for visibility. observer.observe(el); } diff --git a/client/coral-embed/src/wrapBridge.js b/client/coral-embed/src/wrapBridge.js new file mode 100644 index 000000000..9ef8a61ce --- /dev/null +++ b/client/coral-embed/src/wrapBridge.js @@ -0,0 +1,31 @@ +export default bridge => { + // whenRendered will call the callback when the stream has rendered (or now if + // the stream is already rendered). + const whenRendered = callback => bridge.queueWhenRendered(callback); + + // onlyWhenRendered will guard the callback unless the stream is rendered. + const onlyWhenRendered = callback => { + bridge.ensureRendered(); + callback(); + }; + + // Return the limited stream interface. + return { + // Directly map the on/off from the event emitter to the stream. + on: (eventName, callback) => bridge.emitter.on(eventName, callback), + off: (eventName, callback) => bridge.emitter.off(eventName, callback), + + // Queue up the login operation until the stream has been rendered. + login: token => whenRendered(() => bridge.login(token)), + + // Queue up the logout operation until the stream has been rendered. + logout: () => whenRendered(() => bridge.logout()), + + // Remove the stream if it's already been rendered. + remove: () => onlyWhenRendered(() => bridge.remove()), + + // Queue up the plugin config until the embed has rendered. + enablePluginsDebug: () => whenRendered(() => bridge.enablePluginsDebug()), + disablePluginsDebug: () => whenRendered(() => bridge.disablePluginsDebug()), + }; +}; diff --git a/config.js b/config.js index e208f79ec..007aafb46 100644 --- a/config.js +++ b/config.js @@ -224,6 +224,14 @@ const CONFIG = { CACHE_EXPIRY_COMMENT_COUNT: process.env.TALK_CACHE_EXPIRY_COMMENT_COUNT || '1hr', + // EMBED_EXPIRY_TIME is the time that the embed will be cacheable for, sent as + // the max-age= directive on the Cache-Control header. + EMBED_EXPIRY_TIME: ms(process.env.TALK_EMBED_EXPIRY || '24hr'), + + // EMBED_EXPIRY_TIME is the time that the rest of the static files will be + // cacheable for, sent as the max-age= directive on the Cache-Control header. + STATIC_EXPIRY_TIME: ms(process.env.TALK_STATIC_EXPIRY || '1w'), + //------------------------------------------------------------------------------ // Recaptcha configuration //------------------------------------------------------------------------------ diff --git a/middleware/staticFiles.js b/middleware/staticFiles.js new file mode 100644 index 000000000..7bd5b7908 --- /dev/null +++ b/middleware/staticFiles.js @@ -0,0 +1,44 @@ +const express = require('express'); +const path = require('path'); +const staticServer = require('express-static-gzip'); +const { merge } = require('lodash'); +const { EMBED_EXPIRY_TIME, STATIC_EXPIRY_TIME } = require('../config'); + +// EMBED_CACHE_CONTROL_HEADER is the header sent when the file is embed.js. +const EMBED_CACHE_CONTROL_HEADER = [ + 'public', + `max-age=${Math.floor(EMBED_EXPIRY_TIME / 1000)}`, + 'immutable', +].join(', '); + +// Define the options to be applied to all static files, the embed itself has a +// separate override. +const defaultOpts = { + maxAge: STATIC_EXPIRY_TIME, + immutable: true, + setHeaders: (res, path) => { + if (path.includes('/dist/embed.js')) { + // embed.js has a different max-age then the rest of the static files. + res.setHeader('Cache-Control', EMBED_CACHE_CONTROL_HEADER); + } + }, +}; + +// Setup the configuration for the compression options on how to serve files. +const compressionOpts = { + indexFromEmptyFile: false, + enableBrotli: true, + customCompressions: [{ encodingName: 'deflate', fileExtension: 'zz' }], +}; + +// Serve the directories under ../dist. +const dist = path.resolve(path.join(__dirname, '../dist')); + +/** + * middleware in production will serve compressed files if available, otherwise + * it will use express's static middleware. + */ +module.exports = + process.env.NODE_ENV === 'production' + ? staticServer(dist, merge(compressionOpts, defaultOpts)) + : express.static(dist, defaultOpts); diff --git a/package.json b/package.json index e45faabb9..b1f4b5a56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "talk", - "version": "4.6.9", + "version": "4.6.10", "description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net", "main": "app.js", "private": true, @@ -58,6 +58,7 @@ "dependencies": { "@coralproject/gql-merge": "^0.1.0", "@coralproject/graphql-anywhere-optimized": "^0.1.0", + "@ungap/url-search-params": "^0.1.2", "apollo-client": "^1.9.1", "apollo-engine": "^0.8.1", "apollo-server-express": "^1.2.0", @@ -211,7 +212,6 @@ "url-join": "^2.0.2", "url-loader": "^0.6.0", "url-parse": "^1.4.3", - "url-search-params": "^0.9.0", "uuid": "^3.1.0", "webpack": "^3.10.0", "webpack-manifest-plugin": "^2.0.0-rc.2", diff --git a/routes/index.js b/routes/index.js index 913c0862d..f08b94f53 100644 --- a/routes/index.js +++ b/routes/index.js @@ -10,7 +10,7 @@ const compression = require('compression'); const plugins = require('../services/plugins'); const staticTemplate = require('../middleware/staticTemplate'); const nonce = require('../middleware/nonce'); -const staticServer = require('express-static-gzip'); +const staticFiles = require('../middleware/staticFiles'); const { DISABLE_STATIC_SERVER } = require('../config'); const { passport } = require('../services/passport'); const { MOUNT_PATH } = require('../url'); @@ -32,6 +32,8 @@ if (!DISABLE_STATIC_SERVER) { /** * Redirect old embed calls. + * + * TODO: (wyattjoh) remove this on the next minor release */ const oldEmbed = url.resolve(MOUNT_PATH, 'embed.js'); const newEmbed = url.resolve(MOUNT_PATH, 'static/embed.js'); @@ -43,49 +45,15 @@ if (!DISABLE_STATIC_SERVER) { }); /** - * setHeaders adds new headers related to caching to the static files that are - * served. - * - * @param res the response that can be used to set headers on - * @param path the path on the filesystem where the files are being served from + * Setup static file serving. */ - const setHeaders = (res, path) => { - if (path.endsWith('embed.js')) { - // The embed.js file itself should not be cached for a long duration of - // time, as it may change based on the deploy. - res.setHeader('Cache-Control', 'public, max-age=3600'); - } else { - // All static files besides the embed.js file contain hashes, we should - // ensure that any other file is cached for a long duration of time. This - // is cached for 1 week. - res.setHeader('Cache-Control', 'public, max-age=604800, immutable'); - } - }; - - /** - * Serve the directories under dist. - */ - const dist = path.resolve(path.join(__dirname, '../dist')); - if (process.env.NODE_ENV === 'production') { - router.use( - '/static', - staticServer(dist, { - indexFromEmptyFile: false, - enableBrotli: true, - customCompressions: [ - { - encodingName: 'deflate', - fileExtension: 'zz', - }, - ], - setHeaders, - }) - ); - } else { - router.use('/static', express.static(dist, { setHeaders })); - } + router.use('/static', staticFiles); } +//============================================================================== +// Shared Middleware +//============================================================================== + // Add the i18n middleware to all routes. router.use(i18n); diff --git a/webpack.config.js b/webpack.config.js index 547dd2612..e23aa1e2a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -310,19 +310,32 @@ const applyConfig = (entries, root = {}) => config, { entry: entries.reduce( - (entry, { name, path: modulePath, disablePolyfill = false }) => { - const entries = [ - path.join( - __dirname, - 'client/coral-framework/helpers/webpackGlobals' - ), - ]; - if (disablePolyfill) { - entries.push(modulePath); - } else { - entries.unshift('babel-polyfill'); - entries.push(modulePath); + ( + entry, + { + name, + path: modulePath, + disablePolyfill = false, + disableWebpackGlobals = false, } + ) => { + // Create all the entries to be added to the final build target. + const entries = []; + + if (!disablePolyfill) { + entries.push('babel-polyfill'); + } + + if (!disableWebpackGlobals) { + entries.push( + path.join( + __dirname, + 'client/coral-framework/helpers/webpackGlobals' + ) + ); + } + + entries.push(modulePath); entry[name] = entries; @@ -350,7 +363,8 @@ module.exports = [ { name: 'embed', path: path.join(__dirname, 'client/coral-embed/src/index'), - disablePolyfill: process.env.TALK_DISABLE_EMBED_POLYFILL === 'TRUE', + disablePolyfill: true, + disableWebpackGlobals: true, }, ], { diff --git a/yarn.lock b/yarn.lock index 7aa48abdb..8d904c9f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -221,6 +221,11 @@ dependencies: "@types/node" "*" +"@ungap/url-search-params@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@ungap/url-search-params/-/url-search-params-0.1.2.tgz#8ba8c0527543fe675d1c29ae0a2daca842e8ee4f" + integrity sha512-WVk5+lJ+AoNLh2sIDMhnEAgLsVQuI067hWLJCzirErH1GYiy1gs09q4+XZxYWSvdAsslKsaO4q1iXXMx2c72dA== + JSONStream@^1.0.7: version "1.3.3" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.3.tgz#27b4b8fbbfeab4e71bcf551e7f27be8d952239bf" @@ -13362,11 +13367,6 @@ url-regex@~4.1.1: ip-regex "^1.0.1" tlds "^1.187.0" -url-search-params@^0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/url-search-params/-/url-search-params-0.9.0.tgz#e71d7764a6503533cbfe9771b2963cb61ea1c225" - integrity sha1-5x13ZKZQNTPL/pdxspY8th6hwiU= - url@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"