forked from ampproject/amphtml
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathpreconnect.js
403 lines (374 loc) · 13.2 KB
/
preconnect.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
/**
* 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.
*/
/**
* @fileoverview Provides a services to preconnect to a url to warm up the
* connection before the real request can be made.
*/
import {Services} from './services';
import {dev} from './log';
import {getService, registerServiceBuilder} from './service';
import {htmlFor} from './static-template';
import {parseUrlDeprecated} from './url';
import {startsWith} from './string';
import {toWin} from './types';
import {whenDocumentComplete} from './document-ready';
const ACTIVE_CONNECTION_TIMEOUT_MS = 180 * 1000;
const PRECONNECT_TIMEOUT_MS = 10 * 1000;
/**
* @typedef {{
* preload: (boolean|undefined),
* preconnect: (boolean|undefined)
* }}
*/
let PreconnectFeaturesDef;
/** @private {?PreconnectFeaturesDef} */
let preconnectFeatures = null;
/**
* Detect related features if feature detection is supported by the
* browser. Even if this fails, the browser may support the feature.
* @param {!Window} win
* @return {!PreconnectFeaturesDef}
*/
function getPreconnectFeatures(win) {
if (!preconnectFeatures) {
const linkTag = win.document.createElement('link');
const tokenList = linkTag['relList'];
linkTag.as = 'invalid-value';
if (!tokenList || !tokenList.supports) {
return {};
}
preconnectFeatures = {
preconnect: tokenList.supports('preconnect'),
preload: tokenList.supports('preload'),
onlyValidAs: linkTag.as != 'invalid-value',
};
}
return preconnectFeatures;
}
/**
* @param {?PreconnectFeaturesDef} features
*/
export function setPreconnectFeaturesForTesting(features) {
preconnectFeatures = features;
}
class PreconnectService {
/**
* @param {!Window} win
*/
constructor(win) {
/** @private @const {!Document} */
this.document_ = win.document;
/** @private @const {!Element} */
this.head_ = dev().assertElement(win.document.head);
/**
* Origin we've preconnected to and when that connection
* expires as a timestamp in MS.
* @private @const {!Object<string, number>}
*/
this.origins_ = {};
/**
* Urls we've prefetched.
* @private @const {!Object<string, boolean>}
*/
this.urls_ = {};
/** @private @const {!./service/platform-impl.Platform} */
this.platform_ = Services.platformFor(win);
// Mark current origin as preconnected.
this.origins_[parseUrlDeprecated(win.location.href).origin] = true;
/**
* Detect support for the given resource hints.
* Unfortunately not all browsers support this, so this can only
* be used as an affirmative signal.
* @private @const {!PreconnectFeaturesDef}
*/
this.features_ = getPreconnectFeatures(win);
/** @private @const {!./service/timer-impl.Timer} */
this.timer_ = Services.timerFor(win);
}
/**
* Preconnects to a URL. Always also does a dns-prefetch because
* browser support for that is better.
* @param {!./service/ampdoc-impl.AmpDoc} ampdoc
* @param {string} url
* @param {boolean=} opt_alsoConnecting Set this flag if you also just
* did or are about to connect to this host. This is for the case
* where preconnect is issued immediate before or after actual connect
* and preconnect is used to flatten a deep HTTP request chain.
* E.g. when you preconnect to a host that an embed will connect to
* when it is more fully rendered, you already know that the connection
* will be used very soon.
*/
url(ampdoc, url, opt_alsoConnecting) {
ampdoc.whenFirstVisible().then(() => {
this.url_(ampdoc, url, opt_alsoConnecting);
});
}
/**
* Preconnects to a URL. Always also does a dns-prefetch because
* browser support for that is better.
* @param {!./service/ampdoc-impl.AmpDoc} ampdoc
* @param {string} url
* @param {boolean=} opt_alsoConnecting Set this flag if you also just
* did or are about to connect to this host. This is for the case
* where preconnect is issued immediate before or after actual connect
* and preconnect is used to flatten a deep HTTP request chain.
* E.g. when you preconnect to a host that an embed will connect to
* when it is more fully rendered, you already know that the connection
* will be used very soon.
* @private
*/
url_(ampdoc, url, opt_alsoConnecting) {
if (!this.isInterestingUrl_(url)) {
return;
}
const {origin} = parseUrlDeprecated(url);
const now = Date.now();
const lastPreconnectTimeout = this.origins_[origin];
if (lastPreconnectTimeout && now < lastPreconnectTimeout) {
if (opt_alsoConnecting) {
this.origins_[origin] = now + ACTIVE_CONNECTION_TIMEOUT_MS;
}
return;
}
// If we are about to use the connection, don't re-preconnect for
// 180 seconds.
const timeout = opt_alsoConnecting
? ACTIVE_CONNECTION_TIMEOUT_MS
: PRECONNECT_TIMEOUT_MS;
this.origins_[origin] = now + timeout;
// If we know that preconnect is supported, there is no need to do
// dedicated dns-prefetch.
let dns;
if (!this.features_.preconnect) {
dns = this.document_.createElement('link');
dns.setAttribute('rel', 'dns-prefetch');
dns.setAttribute('href', origin);
this.head_.appendChild(dns);
}
const preconnect = this.document_.createElement('link');
preconnect.setAttribute('rel', 'preconnect');
preconnect.setAttribute('href', origin);
preconnect.setAttribute('referrerpolicy', 'origin');
this.head_.appendChild(preconnect);
// Remove the tags eventually to free up memory.
this.timer_.delay(() => {
if (dns && dns.parentNode) {
dns.parentNode.removeChild(dns);
}
if (preconnect.parentNode) {
preconnect.parentNode.removeChild(preconnect);
}
}, 10000);
this.preconnectPolyfill_(ampdoc, origin);
}
/**
* Asks the browser to preload a URL. Always also does a preconnect
* because browser support for that is better.
*
* @param {!./service/ampdoc-impl.AmpDoc} ampdoc
* @param {string} url
* @param {string=} opt_preloadAs
*/
preload(ampdoc, url, opt_preloadAs) {
if (!this.isInterestingUrl_(url)) {
return;
}
if (this.urls_[url]) {
return;
}
this.urls_[url] = true;
this.url(ampdoc, url, /* opt_alsoConnecting */ true);
if (!this.features_.preload) {
return;
}
if (opt_preloadAs == 'document' && this.platform_.isSafari()) {
// Preloading documents currently does not work in Safari,
// because it
// - does not support preloading iframes
// - and uses a different cache for iframes (when loaded without
// as attribute).
return;
}
ampdoc.whenFirstVisible().then(() => {
this.performPreload_(url);
});
}
/**
* Performs a preload using `<link rel="preload">`.
* @param {string} url
* @private
*/
performPreload_(url) {
const preload = htmlFor(this.document_)`
<link rel="preload" referrerpolicy="origin" />`;
preload.setAttribute('href', url);
// Do not set 'as' attribute to correct value for now, for 2 reasons
// - document value is not yet supported and dropped
// - script is blocked due to CSP.
// Due to spec change we now have to also preload with the "as"
// being set to `fetch` when it would previously would be empty.
// See https://github.com/w3c/preload/issues/80
// for details.
if (this.features_.onlyValidAs) {
preload.as = 'fetch';
} else {
preload.as = '';
}
this.head_.appendChild(preload);
// As opposed to preconnect we do not clean this tag up, because there is
// no expectation as to it having an immediate effect.
}
/**
* Skips over non HTTP/HTTPS URL.
* @param {string} url
* @return {boolean}
*/
isInterestingUrl_(url) {
if (startsWith(url, 'https:') || startsWith(url, 'http:')) {
return true;
}
return false;
}
/**
* 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 :)
*
* @param {!./service/ampdoc-impl.AmpDoc} ampdoc
* @param {string} origin
* @private
*/
preconnectPolyfill_(ampdoc, origin) {
// Unfortunately there is no reliable 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.
if (
this.features_.preconnect ||
!(this.platform_.isSafari() || this.platform_.isIos())
) {
return;
}
// Don't attempt to preconnect for ACTIVE_CONNECTION_TIMEOUT_MS since
// we effectively create an active connection.
// TODO(@cramforce): Confirm actual http2 timeout in Safari.
const now = Date.now();
this.origins_[origin] = now + ACTIVE_CONNECTION_TIMEOUT_MS;
// Make the URL change whenever we want to make a new request,
// but make it stay stable in between. While a given page
// would not actually make a new request, another page might
// and with this it has the same URL. If (and that is a big if)
// the server responds with a cacheable response, this reduces
// requests we make. More importantly, though, it reduces URL
// entropy as seen by servers and thus allows reverse proxies
// (read CDNs) to respond more efficiently.
const cacheBust = now - (now % ACTIVE_CONNECTION_TIMEOUT_MS);
const url =
origin +
'/robots.txt?_AMP_safari_preconnect_polyfill_cachebust=' +
cacheBust;
const xhr = new XMLHttpRequest();
xhr.open('HEAD', url, true);
// We only support credentialed preconnect for now.
xhr.withCredentials = true;
xhr.send();
}
}
export class Preconnect {
/**
* @param {!PreconnectService} preconnectService
* @param {!Element} element
*/
constructor(preconnectService, element) {
/** @const @private {!PreconnectService} */
this.preconnectService_ = preconnectService;
/** @const @private {!Element} */
this.element_ = element;
/** @private {?./service/ampdoc-impl.AmpDoc} */
this.ampdoc_ = null;
}
/**
* @return {!./service/ampdoc-impl.AmpDoc}
* @private
*/
getAmpdoc_() {
if (!this.ampdoc_) {
this.ampdoc_ = Services.ampdoc(this.element_);
}
return this.ampdoc_;
}
/**
* Preconnects to a URL. Always also does a dns-prefetch because
* browser support for that is better.
* @param {string} url
* @param {boolean=} opt_alsoConnecting Set this flag if you also just
* did or are about to connect to this host. This is for the case
* where preconnect is issued immediate before or after actual connect
* and preconnect is used to flatten a deep HTTP request chain.
* E.g. when you preconnect to a host that an embed will connect to
* when it is more fully rendered, you already know that the connection
* will be used very soon.
*/
url(url, opt_alsoConnecting) {
this.preconnectService_.url(this.getAmpdoc_(), url, opt_alsoConnecting);
}
/**
* Asks the browser to preload a URL. Always also does a preconnect
* because browser support for that is better.
*
* @param {string} url
* @param {string=} opt_preloadAs
*/
preload(url, opt_preloadAs) {
this.preconnectService_.preload(this.getAmpdoc_(), url, opt_preloadAs);
}
}
/**
* @param {!Element} element
* @return {!Preconnect}
*/
export function preconnectForElement(element) {
const serviceHolder = toWin(element.ownerDocument.defaultView);
registerServiceBuilder(serviceHolder, 'preconnect', PreconnectService);
const preconnectService = getService(serviceHolder, 'preconnect');
return new Preconnect(preconnectService, element);
}
/**
* Preconnects to the source URL and canonical domains to make sure
* outbound navigations are quick. Waits for onload to avoid blocking
* more high priority loads.
* @param {!Document} document
* @return {Promise} When work is done.
*/
export function preconnectToOrigin(document) {
return whenDocumentComplete(document).then(() => {
const element = document.documentElement;
const preconnect = preconnectForElement(element);
const info = Services.documentInfoForDoc(element);
preconnect.url(info.sourceUrl);
preconnect.url(info.canonicalUrl);
});
}