From 22d263c6d2211a663cbc1634b49093d5bb782221 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 13 Dec 2018 20:41:40 +0000 Subject: [PATCH 1/2] 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" From 95435f079385475d66a94e83546b8433155410f7 Mon Sep 17 00:00:00 2001 From: immber Date: Fri, 14 Dec 2018 09:05:52 -0800 Subject: [PATCH 2/2] Docs update (#2120) * merging in typo fixes from docs (rtd) branch * revert generateIntrospectionResult.json * revert git ignore * added asset mgmt to prelaunch checklist * updated cusotm plugin docker onbuild instructions * add google to auth options * add google to auth options * added yarn watch to from source * added dev links to quickstart * typo on following * fixed typos * fixing links * added lazy render and updated lazy load * added version note * renamed asset management to cms-integration * added link to asset scraping --- docs/_config.yml | 4 +- docs/source/01-05-pre-launch-checklist.md | 2 +- ...asset-management.md => cms-integration.md} | 81 ++++++++++++++++--- 3 files changed, 74 insertions(+), 13 deletions(-) rename docs/source/integrating/{asset-management.md => cms-integration.md} (75%) diff --git a/docs/_config.yml b/docs/_config.yml index 57fe6d666..d9de09850 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -104,8 +104,8 @@ sidebar: children: - title: Authentication url: /integrating/authentication/ - - title: Asset Management - url: /integrating/asset-management/ + - title: CMS Integration + url: /integrating/cms-integration/ - title: Asset Scraping url: /integrating/asset-scraping/ - title: Configuring the Comment Stream diff --git a/docs/source/01-05-pre-launch-checklist.md b/docs/source/01-05-pre-launch-checklist.md index 854ead062..01e42b9bd 100644 --- a/docs/source/01-05-pre-launch-checklist.md +++ b/docs/source/01-05-pre-launch-checklist.md @@ -29,7 +29,7 @@ permalink: /pre-launch-checklist/ - See [Authenticating with Talk](/talk/integrating/authentication/) - [ ] Do you want to integrate Talk with your CMS to automate embedding Talk Comment Stream into your site? - - See [Asset Management](/talk/integrating/asset-management/) + - See [CMS Integration](/talk/integrating/cms-integration/) - [ ] Do you want to use Social sign-on? - Facebook diff --git a/docs/source/integrating/asset-management.md b/docs/source/integrating/cms-integration.md similarity index 75% rename from docs/source/integrating/asset-management.md rename to docs/source/integrating/cms-integration.md index 190b67c63..521ab5cf8 100644 --- a/docs/source/integrating/asset-management.md +++ b/docs/source/integrating/cms-integration.md @@ -1,23 +1,81 @@ --- -title: Asset Management -permalink: /integrating/asset-management/ +title: CMS Integration +permalink: /integrating/cms-integration/ --- +## Embedding Comments on Your Site +Talk provides an embed script that you can drop into your site where you want a comments section to appear. By default that script dynamically generate Assets +in Talk in order to make it easier for lighter installations. + +You can find the embed script inside talk under `Configure > Tech Settings > Embed Script`. It should look something like this, but with your domain in place of ``: +``` +
+ +``` + +## Triggering the Comments Section (client side, i.e. Your site) + +When the embed script is triggered on your page load several things are initiated inside Talk, including fetching all comments for the specified article, establishing websocket connections for this user, and checking user’s session for SSO/authentication. + +Instead of greedily triggering the embed to render on _EVERY PAGE LOAD_, we highly recommend implementing a _“lazy”_ rendering strategy to only render the comments section if a user wants to interact with it. This will greatly improve your initial page load performance, and will be critical to managing server resources if you’re running Talk on a heavy-traffic production site. + +We recommend using one of these _“lazy”_ loading strategies: + +#### Scroll to Comments Section +Wait for user to scroll to the comment section before triggering the embed to render. + +You can pass lazy: true to the render options, like so: +``` +Coral.Talk.render(document.getElementById('container'), { + talk: 'https://my-talk-installation.com', + lazy: true, +}); +``` + +Or you can enable lazy rendering by default on all assets using ENV variable `TALK_DEFAULT_LAZY_RENDER=TRUE` + +_*Note: This feature requires Talk version 4.6.8 or greater_ + +#### Show Comments Button + You can hide the comments section until a user clicks button, then trigger the embed to render + +This example uses jQuery to render the embed on the button's click event +``` + + +
+ +
+ + +``` + +## Creating Assets in Talk (server side, i.e. What you need to send to Talk) One of the most frequent questions that we get asked by organizations trying to integrate Talk is: _How do we hook our CMS up to Talk so that articles are in sync?_ -This guide is designed to explain the steps to take your base installation of -Talk and configure it to allow only assets pushed into it from your CMS, and -keep your URL/title in sync. We won't cover here how to install the plugin, as -it is covered in our [Plugins Overview](/talk/plugins/). +### “Lazy Asset Creation” +Talk’s provided embed script by default dynamically generates Assets in Talk based on each unique url that triggers it. The url must reference an existing Permitted Domain. If your articles/stories always have unique urls, then you will not need to modify the default behavior. -## Why do we need to create a plugin? +Assets created in this way will then be scraped to load metadata, see [Asset Scraping](/talk/integrating/asset-scraping/) -By default, Talk will use "Lazy Asset Creation" to dynamically generate Assets -in Talk in order to make it easier for lighter installations. In order to have -more strict control over this flow, we will create a plugin that will: +## Customizing the Integration with a Plugin +In order to have more strict control over the asset creation flow to allow only assets pushed into it from your CMS, and keep your URL/title in sync we will create a plugin. +We will create a plugin that will: 1. Disable "Lazy Asset Creation" by [Overriding a Resolver](#overriding-a-resolver). 2. Create Assets from our CMS by [Creating a New Asset Route](#creating-a-new-asset-route). @@ -25,6 +83,9 @@ more strict control over this flow, we will create a plugin that will: We will then modify our embed so that we can [Target the Asset](#target-the-asset). + +This guide is designed to explain the steps to take your base installation of Talk and configure it. We won't cover here how to install the plugin, as it is covered in our [Plugins Overview](/talk/plugins/). + But first we should grab our basic plugin structure: ```sh