From 086006a540b3b151e8b7345ef480f690cd0ebcef Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 30 Oct 2015 10:09:47 -0700 Subject: [PATCH] Implements URL prefetching and uses it in a few places where it makes sense. Adds the capability for ad networks to preconnect and prefetch resources before being actually loaded. Adds a preconnect polyfill, mostly for Safari, that should speed up 3p embeds on Safari. Optimizes prefetch for 3p iframe to correctly prefetch custom bootstrap iframes and to also prefetch the 3p frame base script. --- ads/README.md | 7 ++ ads/_prefetch.js | 43 +++++++++++ builtins/amp-ad.js | 37 +++++++++- extensions/amp-twitter/0.1/amp-twitter.js | 6 +- src/3p-frame.js | 16 +++++ src/platform.js | 9 +++ src/preconnect.js | 88 ++++++++++++++++++++--- test/_init_tests.js | 14 ++++ test/functional/test-3p-frame.js | 15 +++- test/functional/test-amp-ad.js | 17 ++++- test/functional/test-platform.js | 11 +++ test/functional/test-preconnect.js | 29 ++++++++ 12 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 ads/_prefetch.js diff --git a/ads/README.md b/ads/README.md index bd3794d705c6..6f312971196e 100644 --- a/ads/README.md +++ b/ads/README.md @@ -37,8 +37,15 @@ We will provide the following information to the ad: More information can be provided in a similar fashion if needed (Please file an issue). ### Minimizing HTTP requests + +#### JS reuse across iframes To allow ads to bundle HTTP requests across multiple ad units on the same page the object `window.context.master` will contain the window object of the iframe being elected master iframe for the current page. +#### Preconnect and prefetch +Add the JS URLs that an ad **always** fetches or always connects to (if you know the origin but not the path) to [_prefetch.js](_prefetch.js). + +This triggers prefetch/preconnect when the ad is first seen, so that loads are faster when they come into view. + ### Ad markup Ads are loaded using a the tag given the type of the ad network and name value pairs of configuration. This is an example for the A9 network: diff --git a/ads/_prefetch.js b/ads/_prefetch.js new file mode 100644 index 000000000000..31d2fc9ca8c5 --- /dev/null +++ b/ads/_prefetch.js @@ -0,0 +1,43 @@ +/** + * Copyright 2015 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * URLs to prefetch for a given ad type. + * + * This MUST be kept in sync with actual implementation. + * + * @const {!Object)>} + */ +export const adPrefetch = { + doubleclick: 'https://www.googletagservices.com/tag/js/gpt.js', + a9: 'https://c.amazon-adsystem.com/aax2/assoc.js', + adsense: 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js', +}; + +/** + * URLs to connect to for a given ad type. + * + * This MUST be kept in sync with actual implementation. + * + * @const {!Object)>} + */ +export const adPreconnect = { + adreactor: 'https://adserver.adreactor.com', + doubleclick: [ + 'https://partner.googleadservices.com', + 'https://securepubads.g.doubleclick.net', + ], +}; diff --git a/builtins/amp-ad.js b/builtins/amp-ad.js index 37f192c68421..a784d5c70e02 100644 --- a/builtins/amp-ad.js +++ b/builtins/amp-ad.js @@ -20,7 +20,8 @@ import {isLayoutSizeDefined} from '../src/layout'; import {setStyles} from '../src/style'; import {loadPromise} from '../src/event-helper'; import {registerElement} from '../src/custom-element'; -import {getIframe, listen} from '../src/3p-frame'; +import {getIframe, listen, prefetchBootstrap} from '../src/3p-frame'; +import {adPrefetch, adPreconnect} from '../ads/_prefetch'; /** @@ -111,8 +112,6 @@ export function installAd(win) { /** @override */ createdCallback() { - this.preconnect.threePFrame(); - /** @private {?Element} */ this.iframe_ = null; @@ -131,6 +130,7 @@ export function installAd(win) { /** @override */ buildCallback() { + this.prefetchAd_(); if (this.placeholder_) { this.placeholder_.classList.add('hidden'); } else { @@ -141,6 +141,37 @@ export function installAd(win) { } } + /** + * Prefetches and preconnects URLs related to the ad. + * @private + */ + prefetchAd_() { + // We always need the bootstrap. + prefetchBootstrap(this.getWin()); + let type = this.element.getAttribute('type'); + let prefetch = adPrefetch[type]; + let preconnect = adPreconnect[type]; + if (typeof prefetch == 'string') { + this.preconnect.prefetch(prefetch); + } else if (prefetch) { + prefetch.forEach(p => { + this.preconnect.prefetch(p); + }); + } + if (typeof preconnect == 'string') { + this.preconnect.url(preconnect); + } else if (preconnect) { + preconnect.forEach(p => { + this.preconnect.url(p); + }); + } + // If fully qualified src for ad script is specified we prefetch that. + let src = this.element.getAttribute('src'); + if (src) { + this.preconnect.prefetch(src); + } + } + /** * @override */ diff --git a/extensions/amp-twitter/0.1/amp-twitter.js b/extensions/amp-twitter/0.1/amp-twitter.js index c58df6a72787..a333a5cbc262 100644 --- a/extensions/amp-twitter/0.1/amp-twitter.js +++ b/extensions/amp-twitter/0.1/amp-twitter.js @@ -15,7 +15,7 @@ */ -import {getIframe, listen} from '../../../src/3p-frame'; +import {getIframe, listen, prefetchBootstrap} from '../../../src/3p-frame'; import {isLayoutSizeDefined} from '../../../src/layout'; import {loadPromise} from '../../../src/event-helper'; @@ -26,8 +26,8 @@ class AmpTwitter extends AMP.BaseElement { // This domain serves the actual tweets as JSONP. this.preconnect.url('https://syndication.twitter.com'); // Hosts the script that renders tweets. - this.preconnect.url('https://platform.twitter.com'); - this.preconnect.threePFrame(); + this.preconnect.prefetch('https://platform.twitter.com/widgets.js'); + prefetchBootstrap(this.getWin()); } /** @override */ diff --git a/src/3p-frame.js b/src/3p-frame.js index fe6ea8d61da7..940c8ff80571 100644 --- a/src/3p-frame.js +++ b/src/3p-frame.js @@ -20,6 +20,7 @@ import {getLengthNumeral} from '../src/layout'; import {getService} from './service'; import {documentInfoFor} from './document-info'; import {getMode} from './mode'; +import {preconnectFor} from './preconnect'; import {dashToCamelCase} from './string'; import {parseUrl, assertHttpsUrl} from './url'; @@ -159,6 +160,21 @@ export function addDataAndJsonAttributes_(element, attributes) { } } +/** + * Prefetches URLs related to the bootstrap iframe. + * @param {!Window} parentWindow + * @return {string} + */ +export function prefetchBootstrap(window) { + var url = getBootstrapBaseUrl(window); + var preconnect = preconnectFor(window); + preconnect.prefetch(url); + // While the URL may point to a custom domain, this URL will always be + // fetched by it. + preconnect.prefetch( + 'https://3p.ampproject.net/$internalRuntimeVersion$/f.js'); +} + /** * Returns the base URL for 3p bootstrap iframes. * @param {!Window} parentWindow diff --git a/src/platform.js b/src/platform.js index 693651527007..7258de5b0796 100644 --- a/src/platform.js +++ b/src/platform.js @@ -39,11 +39,20 @@ export class Platform { return /iPhone|iPad|iPod/i.test(this.win.navigator.userAgent); } + /** + * Whether the current browser is Safari. + * @return {boolean} + */ + isSafari() { + return /Safari/i.test(this.win.navigator.userAgent) && !this.isChrome(); + } + /** * Whether the current browser a Chrome browser. * @return {boolean} */ isChrome() { + // Also true for MS Edge :) return /Chrome|CriOS/i.test(this.win.navigator.userAgent); } }; diff --git a/src/preconnect.js b/src/preconnect.js index 59a25178f1e2..655dde42c499 100644 --- a/src/preconnect.js +++ b/src/preconnect.js @@ -23,6 +23,7 @@ import {getService} from './service'; import {parseUrl} from './url'; import {timer} from './timer'; +import {platformFor} from './platform'; class Preconnect { @@ -35,24 +36,32 @@ class Preconnect { this.head_ = win.document.head; /** @private @const {!Object} */ this.origins_ = {}; + /** @private @const {!Object} */ + this.urls_ = {}; + /** @private @const {!Platform} */ + this.platform_ = platformFor(win); // Mark current origin as preconnected. this.origins_[parseUrl(win.location.href).origin] = true; } /** - * Preconnects to a URL. + * Preconnects to a URL. Always also does a dns-prefetch because + * browser support for that is better. * @param {string} url */ url(url) { - var origin = parseUrl(url).origin; + if (!this.isInterestingUrl_(url)) { + return; + } + const origin = parseUrl(url).origin; if (this.origins_[origin]) { return; } this.origins_[origin] = true; - var dns = document.createElement('link'); + const dns = document.createElement('link'); dns.setAttribute('rel', 'dns-prefetch'); dns.setAttribute('href', origin); - var preconnect = document.createElement('link'); + const preconnect = document.createElement('link'); preconnect.setAttribute('rel', 'preconnect'); preconnect.setAttribute('href', origin); this.head_.appendChild(dns); @@ -60,13 +69,76 @@ class Preconnect { // Remove the tags eventually to free up memory. timer.delay(() => { - this.head_.removeChild(dns); - this.head_.removeChild(preconnect); + if (dns.parentNode) { + dns.parentNode.removeChild(dns); + } + if (preconnect.parentNode) { + preconnect.parentNode.removeChild(preconnect); + } }, 10000); + + this.preconnectPolyfill_(origin); + } + + /** + * Asks the browser to prefetch a URL. Always also does a preconnect + * because browser support for that is better. + * @param {string} url + */ + prefetch(url) { + if (!this.isInterestingUrl_(url)) { + return; + } + if (this.urls_[url]) { + return; + } + this.urls_[url] = true; + this.url(url); + const prefetch = document.createElement('link'); + prefetch.setAttribute('rel', 'prefetch'); + prefetch.setAttribute('href', url); + this.head_.appendChild(prefetch); + // As opposed to preconnect we do not clean this tag up, because there is + // no expectation as to it having an immediate effect. + } + + isInterestingUrl_(url) { + if (url.indexOf('https:') == 0 || url.indexOf('http:') == 0) { + return true; + } + return false; } - threePFrame() { - this.url('https://3p.ampproject.net'); + /** + * Safari does not support preconnecting, but due to its significant + * performance benefits we implement this crude polyfill. + * + * We make an image connection to a "well-known" file on the origin adding + * a random query string to bust the cache (no caching because we do want to + * actually open the connection). + * + * This should get us an open SSL connection to these hosts and significantly + * speed up the next connections. + * + * The actual URL is expected to 404. If you see errors for + * amp_preconnect_polyfill in your DevTools console or server log: + * This is expected and fine to leave as is. Its fine to send a non 404 + * response, but please make it small :) + */ + preconnectPolyfill_(origin) { + // Unfortunately there is no way to feature detect whether preconnect is + // supported, so we do this only in Safari, which is the most important + // browser without support for it. This needs to be removed should it + // ever add support. + if (!this.platform_.isSafari()) { + return; + } + const url = origin + '/amp_preconnect_polyfill?' + Math.random(); + // We use an XHR without withCredentials(true), so we do not send cookies + // to the host and the host cannot set cookies. + const xhr = new XMLHttpRequest(); + xhr.open('HEAD', url, true); + xhr.send(); } } diff --git a/test/_init_tests.js b/test/_init_tests.js index d8ce71e6bff6..dce6d96cb7d5 100644 --- a/test/_init_tests.js +++ b/test/_init_tests.js @@ -48,6 +48,20 @@ it.skipOnFirefox = function(desc, fn) { it(desc, fn); }; +// Global cleanup of tags added during tests. Cool to add more +// to selector. +afterEach(() => { + var cleanup = document.querySelectorAll('link,meta'); + for (var i = 0; i < cleanup.length; i++) { + try { + cleanup[i].parentNode.removeChild(cleanup[i]); + } catch (e) { + // This sometimes fails for unknown reasons. + console./*OK*/log(e); + } + } +}); + chai.Assertion.addMethod('attribute', function(attr) { var obj = this._obj; var tagName = obj.tagName.toLowerCase(); diff --git a/test/functional/test-3p-frame.js b/test/functional/test-3p-frame.js index f9e48ab857af..76b74628373c 100644 --- a/test/functional/test-3p-frame.js +++ b/test/functional/test-3p-frame.js @@ -14,8 +14,8 @@ * limitations under the License. */ -import {addDataAndJsonAttributes_, getIframe, getBootstrapBaseUrl} from - '../../src/3p-frame'; +import {addDataAndJsonAttributes_, getIframe, getBootstrapBaseUrl, + prefetchBootstrap} from '../../src/3p-frame'; import {loadPromise} from '../../src/event-helper'; import {setModeForTesting, getMode} from '../../src/mode'; import {resetServiceForTesting} from '../../src/service'; @@ -145,4 +145,15 @@ describe('3p-frame', () => { getBootstrapBaseUrl(window); }).to.throw(/must not be on the same origin as the/); }); + + it('should prefetch bootstrap frame and JS', () => { + prefetchBootstrap(window); + var fetches = document.querySelectorAll( + 'link[rel=prefetch]'); + expect(fetches).to.have.length(2); + expect(fetches[0].href).to.equal( + 'http://ads.localhost:9876/dist.3p/current/frame.max.html'); + expect(fetches[1].href).to.equal( + 'https://3p.ampproject.net/$internalRuntimeVersion$/f.js'); + }); }); diff --git a/test/functional/test-amp-ad.js b/test/functional/test-amp-ad.js index e58201003068..8ee6a9f7ea8b 100644 --- a/test/functional/test-amp-ad.js +++ b/test/functional/test-amp-ad.js @@ -45,7 +45,7 @@ describe('amp-ad', () => { width: 300, height: 250, type: 'a9', - src: 'testsrc', + src: 'https://testsrc', 'data-aax_size': '300x250', 'data-aax_pubname': 'test123', 'data-aax_src': '302', @@ -63,11 +63,24 @@ describe('amp-ad', () => { var data = JSON.parse(fragment); expect(data.type).to.equal('a9'); - expect(data.src).to.equal('testsrc'); + expect(data.src).to.equal('https://testsrc'); expect(data.width).to.equal(300); expect(data.height).to.equal(250); expect(data._context.canonicalUrl).to.equal('https://schema.org/'); expect(data.aax_size).to.equal('300x250'); + + var doc = iframe.ownerDocument; + var fetches = doc.querySelectorAll( + 'link[rel=prefetch]'); + expect(fetches).to.have.length(4); + expect(fetches[0].href).to.equal( + 'http://ads.localhost/dist.3p/current/frame.max.html'); + expect(fetches[1].href).to.equal( + 'https://3p.ampproject.net/$internalRuntimeVersion$/f.js'); + expect(fetches[2].href).to.equal( + 'https://c.amazon-adsystem.com/aax2/assoc.js'); + expect(fetches[3].href).to.equal( + 'https://testsrc/'); }); }); diff --git a/test/functional/test-platform.js b/test/functional/test-platform.js index 9ea63b0b96ff..dc33e324fbea 100644 --- a/test/functional/test-platform.js +++ b/test/functional/test-platform.js @@ -24,16 +24,19 @@ describe('Platform', () => { beforeEach(() => { isIos = false; isChrome = false; + isSafari = false; }); function testUserAgent(userAgentString) { let platform = new Platform({navigator: {userAgent: userAgentString}}); expect(platform.isIos()).to.equal(isIos); expect(platform.isChrome()).to.equal(isChrome); + expect(platform.isSafari()).to.equal(isSafari); } it('iPhone 6 Plus', () => { isIos = true; + isSafari = true; testUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X)' + ' AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0' + ' Mobile/12A4345d Safari/600.1.4'); @@ -41,11 +44,19 @@ describe('Platform', () => { it('iPad 2', () => { isIos = true; + isSafari = true; testUserAgent('Mozilla/5.0 (iPad; CPU OS 7_0 like Mac OS X)' + ' AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0' + ' Mobile/11A465 Safari/9537.53'); }); + it('Desktop Safari', () => { + isSafari = true; + testUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) ' + + 'AppleWebKit/537.75.14 (KHTML, like Gecko) Version/7.0.3 ' + + 'Safari/7046A194A'); + }); + it('Nexus 6 Chrome', () => { isChrome = true; testUserAgent('Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E)' + diff --git a/test/functional/test-preconnect.js b/test/functional/test-preconnect.js index 822fb8373c13..ef144d153e96 100644 --- a/test/functional/test-preconnect.js +++ b/test/functional/test-preconnect.js @@ -23,6 +23,10 @@ describe('preconnect', () => { let clock; let preconnect; + // Factored out to make our linter happy since we don't allow + // bare javascript URLs. + const javascriptUrlPrefix = 'javascript'; + beforeEach(() => { sandbox = sinon.sandbox.create(); clock = sandbox.useFakeTimers(); @@ -38,6 +42,7 @@ describe('preconnect', () => { it('should preconnect', () => { preconnect.url('https://a.preconnect.com/foo/bar'); preconnect.url('https://a.preconnect.com/other'); + preconnect.url(javascriptUrlPrefix + ':alert()'); expect(document.querySelectorAll('link[rel=dns-prefetch]')) .to.have.length(1); expect(document.querySelector('link[rel=dns-prefetch]').href) @@ -46,6 +51,8 @@ describe('preconnect', () => { .to.have.length(1); expect(document.querySelector('link[rel=preconnect]').href) .to.equal('https://a.preconnect.com/'); + expect(document.querySelectorAll('link[rel=prefetch]')) + .to.have.length(0); }); it('should cleanup', () => { @@ -79,4 +86,26 @@ describe('preconnect', () => { expect(document.querySelectorAll('link[rel=preconnect]')) .to.have.length(2); }); + + it('should prefetch', () => { + preconnect.prefetch('https://a.prefetch.com/foo/bar'); + preconnect.prefetch('https://a.prefetch.com/foo/bar'); + preconnect.prefetch('https://a.prefetch.com/other'); + preconnect.prefetch(javascriptUrlPrefix + ':alert()'); + // Also preconnects. + expect(document.querySelectorAll('link[rel=dns-prefetch]')) + .to.have.length(1); + expect(document.querySelector('link[rel=dns-prefetch]').href) + .to.equal('https://a.prefetch.com/'); + expect(document.querySelectorAll('link[rel=preconnect]')) + .to.have.length(1); + expect(document.querySelector('link[rel=preconnect]').href) + .to.equal('https://a.prefetch.com/'); + // Actual prefetch + var fetches = document.querySelectorAll( + 'link[rel=prefetch]'); + expect(fetches).to.have.length(2); + expect(fetches[0].href).to.equal('https://a.prefetch.com/foo/bar'); + expect(fetches[1].href).to.equal('https://a.prefetch.com/other'); + }); });