├── .babelrc
├── .gitignore
├── LICENSE
├── README.md
├── docs
├── demo
│ ├── demo.html
│ ├── forPage.js
│ ├── forSW.js
│ ├── images
│ │ ├── image-L.jpg
│ │ ├── image-M.jpg
│ │ ├── image-S.jpg
│ │ ├── image-XL.jpg
│ │ ├── image-XS.jpg
│ │ ├── image-unknown.jpg
│ │ └── image.jpg
│ └── myUrlRewritingFunction.js
├── howslow-waterfall-to-stats.png
└── index.md
├── examples
├── adjust-image-density
│ └── page.html
├── block-fonts
│ └── sw.js
├── block-third-parties
│ └── sw.js
├── change-images-urls
│ └── sw.js
└── show-connectivity-message
│ └── page.html
├── howSlowForPage.js
├── howSlowForSW.js
└── package.json
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015"]
3 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Fasterize
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | HowSlow is an in-browser bandwidth and RTT (roundtrip time) estimator based on a Service Worker and the Resource Timing API. When deployed on a website, it allows developers to adapt design or behavior according to the each user's bandwidth.
2 |
3 |
4 | ## How does it work?
5 |
6 | Basicaly, the Service Worker includes an algorythm that reads previous [Resource Timing](https://developer.mozilla.org/en-US/docs/Web/API/Resource_Timing_API/Using_the_Resource_Timing_API), uses them to estimate the connectivity metrics and constantly adjusts them after every newly downloaded resource.
7 |
8 | 
9 |
10 | Estimated bandwidth and RTT are available both in the Service Worker and the JavaScript scope of the page to allow a large variety of behaviors.
11 |
12 |
13 | ## What is it good for?
14 |
15 | Send some love ❤️ to your slower users:
16 | + load lower quality images
17 | + avoid loading some heavy third parties such as ads, retargeting tags, AB Testing...
18 | + display a clickable thumbnail instead of a video
19 | + directly load the lowest bitrate in an adaptative bitrate video player
20 | + replace a dynamic Google Map by a static one
21 | + use an aggressive "cache then network" Service Worker strategy
22 | + switch your PWA to offline-first mode when the network gets really bad
23 | + show a "please wait" message on Ajax actions
24 | + reduce the frequency of network requests on autocomplete fields
25 | + avoid loading custom fonts
26 | + send customers to a faster competitor 😬
27 | + ... (add your ideas here)
28 |
29 | But you can also think the other way 🔃 and enhance your website quality on high speed connections:
30 | + load beautiful HD images
31 | + automatically start videos
32 | + load more results at once
33 | + ... (add your ideas here)
34 |
35 |
36 | ## How to install?
37 |
38 | ### Step 1: Load howSlowForPage.js and instantiate it
39 |
40 | Grab the howSlowForPage.js script, transpile it to ES5 (because it's written in ES6), and load it on the page whenever you want. But the sooner it's loaded, the sooner the service worker will be ready.
41 |
42 | And just after it's loaded, you need to instantiate it with the path to the service worker.
43 |
44 | ```html
45 |
46 |
47 | ```
48 |
49 | ### Step 2: Build the service worker and serve it at the root level
50 |
51 | Grab the howSlowForSW.js script, rename it as you like (`mySW.js` in the above example) and serve it with at your website's root. You don't need to transpile the service worker's code, as Service Workers compatible browsers understand ES6.
52 |
53 | ### Step 3: Use the estimated bandwidth or RTT in your code
54 |
55 | If you need the stats in the page's scope, they're available like this:
56 |
57 | ```js
58 | if (howslow.getBandwidth() > 1000) { // Remember, bandwidth is in KBps (1 Kilo Bytes = 8 Kilo bits)
59 | videoPlayer.init();
60 | }
61 |
62 | if (howslow.getRTT() < 50) { // Roundtrip Time is in milliseconds
63 | loadThirdParties();
64 | }
65 | ```
66 |
67 | If you need them in the Service Worker's scope, they are available in the same way:
68 |
69 | ```js
70 | howslow.getBandwidth()
71 | howslow.getRTT()
72 | ```
73 |
74 | You can write your own logic at the top of the current service worker script. What you can't do is write a fetch event listener as there can be only one. But you can use some hook functions: `urlRewritingHook` and `urlBlockingHook`. More details below.
75 |
76 | If you want to keep your service worker's logic separated from howslow, you can use the importScripts() method. But that's one more request before the Service Workers is available.
77 |
78 | ```js
79 | self.importScripts('howSlowForSW.js');
80 |
81 | // ... and here is your own code
82 | ```
83 |
84 | ### The urlRewritingHook
85 |
86 | Use this hook to rewrite the URL before the service workers sends the request. The function should return the new URL or `null` if no change is needed.
87 |
88 | Here is an example that adds an `-hd` suffix to images on fast connections:
89 |
90 | ```js
91 | function urlRewritingHook(url) {
92 |
93 | // Let's intercept every editorial image call
94 | const regexp = /images\/editorial\/(.*)\.jpg$/;
95 | const execResult = regexp.exec(url);
96 |
97 | if (execResult !== null && howslow.getBandwidth() > 1000) {
98 | // Add an "-hd" suffix to the image name
99 | return '/images/editorial/' + execResult[1] + '-hd.jpg';
100 | }
101 |
102 | // Respond null for the default behavior
103 | return null;
104 | }
105 |
106 | // ... and here is the rest of the howSlowForSW.js script
107 | ```
108 |
109 | ### The urlBlockingHook
110 |
111 | Use this hook to cancel the request before it's sent to network. Returning `true` will block the request, returning `false` will let it go.
112 |
113 | Here is an example that blocks a third party script on slow connections:
114 |
115 | ```js
116 | function urlBlockingHook(url) {
117 | return (url === 'https://thirdparty.com/tracker.js' && howslow.getBandwidth() < 50);
118 | }
119 | ```
120 |
121 | ## Some coding examples
122 |
123 | [Block a custom font on slow bandwidth](./examples/block-fonts/sw.js)
124 |
125 | [Adjust density of responsive images according to bandwidth](./examples/adjust-image-density/page.html)
126 |
127 | [Rewrite image URLs to add an "-hd" suffix](./examples/change-images-urls/sw.js)
128 |
129 | [Block a third party script](./examples/block-third-parties/sw.js)
130 |
131 | [Display a "Slow connection detected" message](./examples/show-connectivity-message/page.html)
132 |
133 |
134 | ## How are the averages calculated?
135 |
136 | The algorithm uses "time weighted" averages: the most recent data has more weight than then old one. It is meant to provide a good compromise between reactivity and stability and to avoid the yo-yo effect.
137 |
138 |
139 | ## Will it work on the first page load?
140 |
141 | The first time it's called, the Service Worker needs time to instantiate and initialize itself. For that reason, it only gets available after a few seconds and you might miss entirely the first page load. But it'll be ready for the next user action.
142 |
143 | For returning visitors, HowSlow will first serve the last known values and adjust to the new bandwidth ASAP, in case it has changed in between.
144 |
145 |
146 | ## Ok, great. But what's the difference with the Network Information API?
147 |
148 | The [Network Information API](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation) is not yet mature and is only able to provide bandwidth/RTT estimations on Chrome Desktop. Smartphones are not supported.
149 |
150 |
151 | ## Compatibility
152 |
153 | It's compatible with the latest versions of Chrome, Firefox and Safari. Unfortunately, Edge (v17) is not compatible. We're looking for a workaround.
154 |
155 | However, Service Workers are quite unpredictable and you should not rely on this tool for important tasks. Use it for **progressive enhancement**.
156 |
157 |
158 | ## Demo
159 |
160 | Demo page: https://fasterize.github.io/HowSlow/demo/demo.html
161 |
162 | Mirror: https://gmetais.github.io/howslow/demo/demo.html
163 |
164 |
165 | ## Authors
166 |
167 | The Fasterize team. [Fasterize](https://www.fasterize.com) is a frontend optimization platform that makes your website super-fast, super-easily.
168 |
169 |
--------------------------------------------------------------------------------
/docs/demo/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | HowSlow demo page
4 |
5 |
21 |
22 |
23 |
This page reloads indefinitely the same image. But a Service Worker intercepts the requests and chooses what images it wants, based on a bandwidth estimation.
24 |
Estimated bandwidth: unknown KBps Estimated RTT: unknown ms
25 |
26 |
27 |
28 |
29 |
30 |
31 |
88 |
89 |
90 |
93 |
94 |
--------------------------------------------------------------------------------
/docs/demo/forPage.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
4 |
5 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
6 |
7 | var HowSlowForPage = function () {
8 | function HowSlowForPage(swPath) {
9 | var _this = this;
10 |
11 | _classCallCheck(this, HowSlowForPage);
12 |
13 | this.bandwidth = undefined;
14 | this.rtt = undefined;
15 | this.firstRequestSent = false;
16 | this.navigationStart = 0;
17 |
18 | this.initSW(swPath).then(function () {
19 | return _this.listenToSW();
20 | });
21 |
22 | this.storageIO();
23 | }
24 |
25 | _createClass(HowSlowForPage, [{
26 | key: 'getBandwidth',
27 | value: function getBandwidth() {
28 | return this.bandwidth;
29 | }
30 | }, {
31 | key: 'getRTT',
32 | value: function getRTT() {
33 | return this.rtt;
34 | }
35 |
36 | // Initializes the Service Worker, or at least tries
37 |
38 | }, {
39 | key: 'initSW',
40 | value: function initSW(swPath) {
41 | return new Promise(function (resolve, reject) {
42 | if (!('serviceWorker' in window.navigator)) {
43 | // No browser support
44 | reject('Service Workers not supported');
45 | }
46 |
47 | if (window.navigator.serviceWorker.controller) {
48 | if (window.navigator.serviceWorker.controller.scriptURL.indexOf(swPath) >= 0) {
49 | // The service worker is already active
50 | resolve();
51 | } else {
52 | reject('Giving up. Service Worker conflict with: ' + window.navigator.serviceWorker.controller.scriptURL);
53 | }
54 | } else {
55 | window.navigator.serviceWorker.register(swPath).then(window.navigator.serviceWorker.ready).then(function (serviceWorkerRegistration) {
56 | // The service worker is registered and should even be ready now
57 | resolve();
58 | });
59 | }
60 | });
61 | }
62 |
63 | // Geting ready to receive messages from the Service Worker
64 |
65 | }, {
66 | key: 'listenToSW',
67 | value: function listenToSW() {
68 | var _this2 = this;
69 |
70 | window.navigator.serviceWorker.onmessage = function (event) {
71 | if (event.data.command === 'timingsPlz') {
72 | // The Service Workers asks for resource timings
73 | var timings = _this2.readLatestResourceTimings();
74 |
75 | if (timings.length > 0) {
76 | _this2.sendResourceTimings(timings);
77 | }
78 | } else if (event.data.command === 'stats') {
79 | // The Service Workers sends the latest stats
80 | _this2.bandwidth = event.data.bandwidth;
81 | _this2.rtt = event.data.rtt;
82 | }
83 | };
84 | }
85 |
86 | // Gathers the latest ResourceTimings and sends them to SW
87 |
88 | }, {
89 | key: 'sendResourceTimings',
90 | value: function sendResourceTimings(simplifiedTimings) {
91 | try {
92 | navigator.serviceWorker.controller.postMessage({
93 | 'command': 'eatThat',
94 | 'timings': simplifiedTimings
95 | });
96 | } catch (error) {}
97 | }
98 |
99 | // Gathers the ResourceTimings from the API
100 |
101 | }, {
102 | key: 'readLatestResourceTimings',
103 | value: function readLatestResourceTimings() {
104 | var _this3 = this;
105 |
106 | // Not compatible browsers
107 | if (!window.performance || !window.performance.getEntriesByType || !window.performance.timing) {
108 | return [];
109 | }
110 |
111 | var timings = [];
112 |
113 | if (!this.firstRequestSent) {
114 |
115 | // Save this for later
116 | this.navigationStart = window.performance.timing.navigationStart;
117 |
118 | // The first HTML resource is as intersting as the others... maybe even more!
119 | timings.push({
120 | name: window.location.href,
121 | transferSize: window.performance.timing.transferSize,
122 | domainLookupStart: window.performance.timing.domainLookupStart,
123 | domainLookupEnd: window.performance.timing.domainLookupEnd,
124 | connectStart: window.performance.timing.connectStart,
125 | connectEnd: window.performance.timing.connectEnd,
126 | requestStart: window.performance.timing.requestStart,
127 | responseStart: window.performance.timing.responseStart,
128 | responseEnd: window.performance.timing.responseEnd
129 | });
130 |
131 | this.firstRequestSent = true;
132 | }
133 |
134 | window.performance.getEntriesByType('resource').forEach(function (timing) {
135 | timings.push({
136 | name: timing.name,
137 | transferSize: timing.transferSize,
138 | domainLookupStart: Math.round(_this3.navigationStart + timing.domainLookupStart),
139 | domainLookupEnd: Math.round(_this3.navigationStart + timing.domainLookupEnd),
140 | connectStart: Math.round(_this3.navigationStart + timing.connectStart),
141 | connectEnd: Math.round(_this3.navigationStart + timing.connectEnd),
142 | secureConnectionStart: Math.round(_this3.navigationStart + timing.secureConnectionStart),
143 | requestStart: Math.round(_this3.navigationStart + timing.requestStart),
144 | responseStart: Math.round(_this3.navigationStart + timing.responseStart),
145 | responseEnd: Math.round(_this3.navigationStart + timing.responseEnd)
146 | });
147 | });
148 |
149 | // Now lets clear resourceTimings
150 | window.performance.clearResourceTimings();
151 | window.performance.setResourceTimingBufferSize(200);
152 |
153 | // TODO: add an option to avoid clearing ResourceTimings...
154 | // ... some other scripts might need them!
155 |
156 | return timings;
157 | }
158 |
159 | // On the SW's side, stats are saved in IndexedDB. Here we have access to LocalStorage.
160 |
161 | }, {
162 | key: 'storageIO',
163 | value: function storageIO() {
164 | var _this4 = this;
165 |
166 | // When leaving the page, save stats into LocalStorage for faster ignition
167 | window.addEventListener('unload', function () {
168 | if (_this4.bandwidth || _this4.rtt) {
169 | window.localStorage.setItem('howslow', _this4.bandwidth + ',' + _this4.rtt);
170 | }
171 | });
172 |
173 | // And when arriving on the page, retrieve stats
174 | var stats = window.localStorage.getItem('howslow');
175 | if (stats) {
176 | stats = stats.split(',');
177 | this.bandwidth = stats[0] || undefined;
178 | this.rtt = stats[1] || undefined;
179 | }
180 | }
181 | }]);
182 |
183 | return HowSlowForPage;
184 | }();
185 |
--------------------------------------------------------------------------------
/docs/demo/forSW.js:
--------------------------------------------------------------------------------
1 | function urlRewritingHook(url) {
2 |
3 | // This is some demo code. Adapt to your needs.
4 |
5 | // Let's intercept every call to image.jpg
6 | const regexp = /images\/image\.jpg\?timestamp=(.*)$/;
7 | const execResult = regexp.exec(url);
8 |
9 | if (execResult !== null) {
10 |
11 | // ... and choose the right image!
12 | var bandwidth = howslow.getBandwidth();
13 |
14 | if (bandwidth > 4000) {
15 | return 'images/image-XL.jpg?timestamp=' + execResult[1];
16 | } else if (bandwidth > 1000) {
17 | return 'images/image-L.jpg?timestamp=' + execResult[1];
18 | } else if (bandwidth > 200) {
19 | return 'images/image-M.jpg?timestamp=' + execResult[1];
20 | } else if (bandwidth > 50) {
21 | return 'images/image-S.jpg?timestamp=' + execResult[1];
22 | } else if (bandwidth > 10) {
23 | return 'images/image-XS.jpg?timestamp=' + execResult[1];
24 | } else {
25 | return 'images/image-unknown.jpg?timestamp=' + execResult[1];
26 | }
27 | }
28 |
29 | // Return null for urls you don't want to change
30 | return null;
31 | }
32 |
33 | /* ----- ↑ Write your own service workers rules above this line ↑ ----- */
34 | /* ----- ↓ Change below this line at your own risks ↓ -------------------------------- */
35 |
36 | // Service Worker initialization
37 | self.addEventListener('install', () => {
38 | self.skipWaiting(); // Activate worker immediately
39 | });
40 |
41 | self.addEventListener('activate', () => {
42 | if (self.clients && self.clients.claim) {
43 | // Make it available immediately
44 | self.clients.claim().then(() => {
45 | // The service worker is ready to work
46 |
47 | // The attached pages might already have some resource timings available.
48 | // Let's ask!
49 | howslow.askTimingsToClients();
50 | });
51 | }
52 | });
53 |
54 | // Intercept requests
55 | self.addEventListener('fetch', (event) => {
56 |
57 | if (typeof urlBlockingHook === 'function' && urlBlockingHook(event.request.url) === true) {
58 | event.respondWith(new Response('', {
59 | status: 446,
60 | statusText: 'Blocked by Service Worker'
61 | }));
62 | return;
63 | }
64 |
65 | let modifiedUrl = (typeof urlRewritingHook === 'function') ? urlRewritingHook(event.request.url) : null;
66 | let options = {};
67 |
68 | if (modifiedUrl) {
69 | // Add credentials to the request otherwise the fetch method opens a new connection
70 | options.credentials = 'include';
71 | }
72 |
73 | event.respondWith(
74 | fetch((modifiedUrl || event.request), options)
75 | .then(function(response) {
76 | // Save the content-length header
77 | howslow.addContentLength(response.url, response);
78 | return response;
79 | })
80 | );
81 | });
82 |
83 |
84 | class HowSlowForSW {
85 |
86 | constructor() {
87 | this.allTimings = [];
88 | this.allContentLengths = [];
89 | this.allIntervals = [];
90 | this.INTERVAL_DURATION = 25; // in milliseconds
91 |
92 | // That's our supposed service worker initialization time
93 | // TODO: replace by the reference time used inside the SW's Resource Timings
94 | // (I have no idea how to find it)
95 | this.epoch = Date.now();
96 |
97 | // Start the ticker
98 | this.tick();
99 |
100 | // Listen to the broadcast responses
101 | self.addEventListener('message', (event) => {
102 | if (event.data.command === 'eatThat') {
103 | // Some new timings just arrived from a page
104 | event.data.timings.forEach((timing) => {
105 | this.addOneTiming(timing)
106 | });
107 | }
108 | });
109 |
110 | this.initDatabase();
111 | }
112 |
113 | // Every second:
114 | tick() {
115 | // Do that...
116 | this.refreshStats();
117 | this.sendStatsToClients();
118 |
119 | // ... and repeat
120 | setTimeout(() => {
121 | this.tick();
122 | }, 1000);
123 | }
124 |
125 | getBandwidth() {
126 | if (this.bandwidth) {
127 | return this.bandwidth;
128 | }
129 |
130 | // If we couldn't estimate bandwidth yet, but we've got a record in database
131 | // We serve the saved bandwidth
132 | if (!this.connectionTypeFromDatabase
133 | || !self.navigator.connection
134 | || !self.navigator.connection.type
135 | || this.connectionTypeFromDatabase === self.navigator.connection.type) {
136 |
137 | return this.bandwidthFromDatabase;
138 | }
139 |
140 | return undefined;
141 | }
142 |
143 | getRTT() {
144 | if (this.rtt) {
145 | return this.rtt;
146 | }
147 |
148 | // If we couldn't estimate bandwidth yet, but we've got a record in database
149 | // We serve the saved bandwidth
150 | if (!this.connectionTypeFromDatabase
151 | || !self.navigator.connection
152 | || !self.navigator.connection.type
153 | || this.connectionTypeFromDatabase === self.navigator.connection.type) {
154 |
155 | return this.rttFromDatabase;
156 | }
157 |
158 | return undefined;
159 | }
160 |
161 | // Updates bandwidth & rtt
162 | refreshStats() {
163 |
164 | // Update the data from resource timings
165 | this.refreshTimings();
166 |
167 | // Use the data to estimate bandwidth
168 | this.bandwidth = this.estimateBandwidth();
169 | this.rtt = this.estimateRTT();
170 |
171 | // If the bandwith or the RTT were correctly estimated,
172 | // we save them to database
173 | if (this.bandwidth || this.rtt) {
174 | this.saveStats();
175 | }
176 | }
177 |
178 | // Collects the latest resource timings
179 | refreshTimings() {
180 |
181 | if (self.performance && self.performance.getEntriesByType) {
182 | // If the Service Worker has access to the Resource Timing API,
183 | // It's easy, we just read it.
184 |
185 | self.performance.getEntriesByType('resource').forEach((timing) => {
186 | this.addOneTiming(this.simplifyTimingObject(timing));
187 | });
188 |
189 | // Then we empty the history
190 | self.performance.clearResourceTimings();
191 |
192 | // "The clearResourceTimings() method removes all performance entries [...] and sets
193 | // the size of the performance data buffer to zero. To set the size of the browser's
194 | // performance data buffer, use the Performance.setResourceTimingBufferSize() method."
195 | self.performance.setResourceTimingBufferSize(200);
196 | }
197 |
198 | // TODO : the "else" part, when the Service Workers doesn't have access to
199 | // the Resource Timing API (Microsoft Edge)
200 | }
201 |
202 | // Sends a request to all clients for their resource timings.
203 | askTimingsToClients() {
204 | this.sendMessageToAllClients({
205 | command: 'timingsPlz'
206 | });
207 | }
208 |
209 | // Send bandwidth and RTT to all clients
210 | sendStatsToClients() {
211 | this.sendMessageToAllClients({
212 | command: 'stats',
213 | bandwidth: this.getBandwidth(),
214 | rtt: this.getRTT()
215 | });
216 | }
217 |
218 | // Sends a message to all clients
219 | sendMessageToAllClients(json) {
220 | self.clients.matchAll()
221 | .then((clients) => {
222 | clients.forEach((client) => {
223 | client.postMessage(json);
224 | });
225 | });
226 | }
227 |
228 | // Saves one timing in the allTimings list
229 | // only if it doesn't look like it comes from the browser's cache
230 | addOneTiming(timing) {
231 |
232 | // If we don't have the transfer size (Safari & Edge don't provide it)
233 | // than let's try to read it from the Content-Length headers.
234 | if (!timing.transferSize) {
235 | timing.transferSize = this.findContentLength(timing.name);
236 | }
237 |
238 | const time = timing.responseEnd - timing.responseStart;
239 |
240 | // If the transfer is ridiculously fast (> 200Mbps), then it most probably comes
241 | // from browser cache and timing is not reliable.
242 | if (time > 0 && timing.transferSize > 0 && timing.transferSize / time < 26214) {
243 | this.allTimings.push(timing);
244 | this.splitTimingIntoIntervals(timing);
245 | }
246 | }
247 |
248 | // To be able to estimate the bandwidth, we split this resource transferSize into
249 | // time intervals and add them to our timeline.
250 | splitTimingIntoIntervals(timing) {
251 | let startingBlock = Math.floor((timing.responseStart - this.epoch) / this.INTERVAL_DURATION);
252 | let endingBlock = Math.floor((timing.responseEnd - this.epoch) / this.INTERVAL_DURATION);
253 | let bytesPerBlock = timing.transferSize / ((endingBlock - startingBlock + 1));
254 |
255 | for (var i = startingBlock; i <= endingBlock; i++) {
256 | this.allIntervals[i] = (this.allIntervals[i] || 0) + bytesPerBlock;
257 | }
258 | }
259 |
260 | // What a good idea we had, to save the Content-Length headers!
261 | // Because we need one.
262 | findContentLength(url) {
263 | for (var i = this.allContentLengths.length - 1; i >= 0; i--) {
264 | if (this.allContentLengths[i].url === url) {
265 | return parseInt(this.allContentLengths[i].size, 10);
266 | }
267 | }
268 | }
269 |
270 | // Saves the content-length data from a fetched response header
271 | addContentLength(url, response) {
272 | if (response.type !== 'opaque' && response.headers.has('content-length')) {
273 | this.allContentLengths.push({
274 | url: url,
275 | size: response.headers.get('content-length')
276 | });
277 | }
278 | }
279 |
280 | // Reads timings and estimates bandwidth
281 | estimateBandwidth() {
282 |
283 | // Let's estimate the bandwidth for some different periods of times (in seconds)
284 | const ages = [20, 60, 300, 86400]; // 20s, 1m, 5m, 1d
285 | const bandwidths = ages.map((bw) => this.estimateBandwidthForAPeriod(bw));
286 |
287 | let result = this.averageWithWeight(bandwidths);
288 |
289 | // Always cap bandwidth with the theorical max speed of underlying network
290 | // (when the Network Information API is available, of course)
291 | // It makes the library more reactive when, for exemple, the user
292 | // sudenly looses its 3G signal and gets 2G instead.
293 | result = Math.min(result, this.getDownlinkMax());
294 |
295 | return Math.round(result);
296 | }
297 |
298 | // Estimates bandwidth for the last given number of seconds
299 | estimateBandwidthForAPeriod(numberOfSeconds) {
300 |
301 | // Now, minus the number of minutes
302 | const from = Date.now() - this.epoch - (numberOfSeconds * 1000);
303 |
304 | // Retrieves corresponding cells in the timeline array
305 | const newArray = this.allIntervals.slice(from / this.INTERVAL_DURATION);
306 |
307 | if (newArray.length === 0) {
308 | return undefined;
309 | }
310 |
311 | // Sums up the transfered size in this duration
312 | const transferedSize = newArray.reduce((a, b) => a + b);
313 |
314 | // Skip estimating bandwidth if too few kilobytes were collected
315 | if (transferedSize < 51200) {
316 | return undefined;
317 | }
318 |
319 | // Now let's use the 90th percentile of all values
320 | // From my different tests, that percentile provides good results
321 | const nineteenthPercentile = this.percentile(newArray, .9);
322 |
323 | // Convert bytes per (this.INTERVAL_DURATION)ms to kilobytes per second (kilobytes, not kilobits!)
324 | const mbps = nineteenthPercentile * 1000 / this.INTERVAL_DURATION / 1024;
325 |
326 | return mbps;
327 | }
328 |
329 | // Reads timings and estimates Round Trip Time
330 | estimateRTT() {
331 | // Same as for bandwidth, we start by estimating the RTT on several periods of time
332 | const ages = [20, 60, 300, 86400]; // 20s, 1m, 5m, 1d
333 | const rtts = ages.map((bw) => this.estimateRTTForAPeriod(bw));
334 |
335 | return Math.round(this.averageWithWeight(rtts));
336 | }
337 |
338 | // Estimates RTT for the last given number of seconds
339 | estimateRTTForAPeriod(numberOfSeconds) {
340 |
341 | // Now, minus the number of minutes
342 | const from = Date.now() - (numberOfSeconds * 1000);
343 |
344 | let pings = this.allTimings.filter(timing => {
345 | return timing.responseEnd >= from;
346 | }).map(timing => {
347 | // The estimated RTT for one request is an average of:
348 | // DNS lookup time + First connection + SSL handshake + Time to First Byte
349 | // in milliseconds.
350 | //
351 | // Note: we can't rely on timing.secureConnectionStart because IE doesn't provide it.
352 | // But we're always on HTTPS, so let's just count TCP connection as two roundtrips.
353 | //
354 | const dns = timing.domainLookupEnd - timing.domainLookupStart;
355 | const tcp = timing.connectEnd - timing.connectStart;
356 | const ttfb = timing.responseStart - timing.requestStart;
357 |
358 | // Let's consider that any timing under 10ms is not valuable
359 | const roundtripsCount = (dns > 10) + ((tcp > 10) * 2) + (ttfb > 10);
360 | return roundtripsCount ? Math.round((dns + tcp + ttfb) / roundtripsCount) : null;
361 | });
362 |
363 | // Skip estimating RTT if too few requests were analyzed
364 | if (pings.length < 3) {
365 | return undefined;
366 | }
367 |
368 | // Let's use the 20th percentile here, to eliminate servers' slowness
369 | return this.percentile(pings, .2);
370 | }
371 |
372 | // Returns the value at a given percentile in a numeric array.
373 | // Not very accurate, but accurate enough for our needs.
374 | percentile(arr, p) {
375 |
376 | // Remove undefineds and transfer to a new array
377 | let newArray = arr.filter((cell) => cell !== undefined);
378 |
379 | // Fail if there are no results
380 | if (newArray.length === 0) {
381 | return undefined;
382 | }
383 |
384 | newArray.sort((a, b) => a - b);
385 |
386 | return newArray[Math.floor(newArray.length * p)];
387 | }
388 |
389 | // Returns the average of the array, but gives much more weight to the first values
390 | averageWithWeight(arr) {
391 | let total = 0;
392 | let totalWeights = 0;
393 |
394 | for (var i = 0; i < arr.length; i++) {
395 | if (arr[i] !== undefined) {
396 |
397 | let weight = 1 / Math.pow(i + 1, 3);
398 | // With that formula:
399 | // - the weight of the 1st value is 1
400 | // - of the 2nd value is 1/8
401 | // - of the 3rd value is 1/27
402 | // - of the 4th value is 1/64
403 | // ...
404 |
405 | total += arr[i] * weight;
406 | totalWeights += weight;
407 | }
408 | }
409 |
410 | if (totalWeights === 0) {
411 | return undefined;
412 | }
413 |
414 | return total / totalWeights;
415 | }
416 |
417 | simplifyTimingObject(timing) {
418 | return {
419 | name: timing.name,
420 | transferSize: timing.transferSize,
421 | domainLookupStart: Math.round(this.epoch + timing.domainLookupStart),
422 | domainLookupEnd: Math.round(this.epoch + timing.domainLookupEnd),
423 | connectStart: Math.round(this.epoch + timing.connectStart),
424 | connectEnd: Math.round(this.epoch + timing.connectEnd),
425 | requestStart: Math.round(this.epoch + timing.requestStart),
426 | responseStart: Math.round(this.epoch + timing.responseStart),
427 | responseEnd: Math.round(this.epoch + timing.responseEnd)
428 | };
429 | }
430 |
431 | getDownlinkMax() {
432 | if (self.navigator.connection && self.navigator.connection.downlinkMax > 0) {
433 | return self.navigator.connection.downlinkMax * 256; // convert Mbps to KBps
434 | }
435 | return Infinity;
436 | }
437 |
438 | initDatabase() {
439 | // Open database connection
440 | let dbPromise = self.indexedDB.open('howslow', 1)
441 |
442 | dbPromise.onupgradeneeded = (event) => {
443 | if (!event.target.result.objectStoreNames.contains('bw')) {
444 | event.target.result.createObjectStore('bw');
445 | }
446 | };
447 |
448 | dbPromise.onsuccess = (event) => {
449 | this.database = event.target.result;
450 | this.retrieveStats();
451 | };
452 |
453 | // Not handling DB errors cause it's ok, we can still work without DB
454 | }
455 |
456 | // Saves bandwidth & RTT to IndexedDB
457 | saveStats() {
458 | let object = {
459 | bandwidth: this.bandwidth,
460 | rtt: this.rtt,
461 | connectionType: self.navigator.connection && self.navigator.connection.type
462 | };
463 |
464 | try {
465 | this.database.transaction('bw', 'readwrite').objectStore('bw').put(object, 1);
466 | } catch(error) {
467 | // Silent error
468 | }
469 | }
470 |
471 | // Reads the latest known bandwidth & RTT from IndexedDB
472 | retrieveStats() {
473 | try {
474 | this.database.transaction('bw', 'readonly').objectStore('bw').get(1).onsuccess = (event) => {
475 | if (event.target.result) {
476 | this.bandwidthFromDatabase = event.target.result.bandwidth || undefined;
477 | this.rttFromDatabase = event.target.result.rtt || undefined;
478 | this.connectionTypeFromDatabase = event.target.result.connectionType;
479 | }
480 | };
481 | } catch(error) {
482 | // Silent error
483 | }
484 | }
485 | }
486 |
487 | // Let's go!
488 | self.howslow = new HowSlowForSW();
--------------------------------------------------------------------------------
/docs/demo/images/image-L.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image-L.jpg
--------------------------------------------------------------------------------
/docs/demo/images/image-M.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image-M.jpg
--------------------------------------------------------------------------------
/docs/demo/images/image-S.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image-S.jpg
--------------------------------------------------------------------------------
/docs/demo/images/image-XL.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image-XL.jpg
--------------------------------------------------------------------------------
/docs/demo/images/image-XS.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image-XS.jpg
--------------------------------------------------------------------------------
/docs/demo/images/image-unknown.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image-unknown.jpg
--------------------------------------------------------------------------------
/docs/demo/images/image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image.jpg
--------------------------------------------------------------------------------
/docs/demo/myUrlRewritingFunction.js:
--------------------------------------------------------------------------------
1 | function urlRewritingHook(url) {
2 |
3 | // This is some demo code. Adapt to your needs.
4 |
5 | // Let's intercept every call to image.jpg
6 | const regexp = /images\/image\.jpg\?timestamp=(.*)$/;
7 | const execResult = regexp.exec(url);
8 |
9 | if (execResult !== null) {
10 |
11 | // ... and choose the right image!
12 | var bandwidth = howslow.getBandwidth();
13 |
14 | if (bandwidth > 4000) {
15 | return 'images/image-XL.jpg?timestamp=' + execResult[1];
16 | } else if (bandwidth > 1000) {
17 | return 'images/image-L.jpg?timestamp=' + execResult[1];
18 | } else if (bandwidth > 200) {
19 | return 'images/image-M.jpg?timestamp=' + execResult[1];
20 | } else if (bandwidth > 50) {
21 | return 'images/image-S.jpg?timestamp=' + execResult[1];
22 | } else if (bandwidth > 10) {
23 | return 'images/image-XS.jpg?timestamp=' + execResult[1];
24 | } else {
25 | return 'images/image-unknown.jpg?timestamp=' + execResult[1];
26 | }
27 | }
28 |
29 | // Return null for urls you don't want to change
30 | return null;
31 | }
--------------------------------------------------------------------------------
/docs/howslow-waterfall-to-stats.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/howslow-waterfall-to-stats.png
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | [Demo page](demo/demo.html)
--------------------------------------------------------------------------------
/examples/adjust-image-density/page.html:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 |
25 |
49 |
50 |
--------------------------------------------------------------------------------
/examples/block-fonts/sw.js:
--------------------------------------------------------------------------------
1 | // Load howslow
2 | self.importScripts('howSlowForSW.js');
3 |
4 | // Don't just block the WOFF2 file, block all extentions because when
5 | // a font is in error, the browser will try the other font formats.
6 | function urlBlockingHook(url) {
7 | const execResult = /\/static\/fonts\/fontname\.(woff|woff2|ttf)/.exec(url);
8 | return (execResult !== null && howslow.getBandwidth() < 50);
9 | }
--------------------------------------------------------------------------------
/examples/block-third-parties/sw.js:
--------------------------------------------------------------------------------
1 | // Load howslow
2 | self.importScripts('howSlowForSW.js');
3 |
4 | // Returning true will block the resource with the HTTP code "446 Blocked by Service Worker"
5 | function urlBlockingHook(url) {
6 | return (url === 'https://thirdparty.com/tracker.js' && howslow.getBandwidth() < 50);
7 | }
--------------------------------------------------------------------------------
/examples/change-images-urls/sw.js:
--------------------------------------------------------------------------------
1 | // Load howslow
2 | self.importScripts('howSlowForSW.js');
3 |
4 | // Here we use the urlRewritingHook. This function is called on each resource
5 | // fetched by the page.
6 | // If the return value is null, nothing is changed.
7 | // If the value is an URL, it replaces the inital URL.
8 | function urlRewritingHook(url) {
9 |
10 | // Let's intercept every editorial image call
11 | const regexp = /images\/editorial\/(.*)\.jpg$/;
12 | const execResult = regexp.exec(url);
13 |
14 | if (execResult !== null && howslow.getBandwidth() > 1000) {
15 | // Add an "-hd" suffix to the image name
16 | return '/images/editorial/' + execResult[1] + '-hd.jpg';
17 | }
18 |
19 | // Respond null for the default behavior
20 | return null;
21 | }
--------------------------------------------------------------------------------
/examples/show-connectivity-message/page.html:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
36 |
--------------------------------------------------------------------------------
/howSlowForPage.js:
--------------------------------------------------------------------------------
1 | class HowSlowForPage {
2 |
3 | constructor(swPath) {
4 | this.bandwidth = undefined;
5 | this.rtt = undefined;
6 | this.firstRequestSent = false;
7 | this.navigationStart = 0;
8 |
9 | this.initSW(swPath).then(() => this.listenToSW());
10 |
11 | this.storageIO();
12 | }
13 |
14 | getBandwidth() {
15 | return this.bandwidth;
16 | }
17 |
18 | getRTT() {
19 | return this.rtt;
20 | }
21 |
22 | // Initializes the Service Worker, or at least tries
23 | initSW(swPath) {
24 | return new Promise((resolve, reject) => {
25 | if (!('serviceWorker' in window.navigator)) {
26 | // No browser support
27 | reject('Service Workers not supported');
28 | }
29 |
30 | if (window.navigator.serviceWorker.controller) {
31 | if (window.navigator.serviceWorker.controller.scriptURL.indexOf(swPath) >= 0) {
32 | // The service worker is already active
33 | resolve();
34 | } else {
35 | reject('Giving up. Service Worker conflict with: ' + window.navigator.serviceWorker.controller.scriptURL);
36 | }
37 | } else {
38 | window.navigator.serviceWorker.register(swPath)
39 | .then(window.navigator.serviceWorker.ready)
40 | .then(function(serviceWorkerRegistration) {
41 | // The service worker is registered and should even be ready now
42 | resolve();
43 | });
44 | }
45 | });
46 | }
47 |
48 | // Geting ready to receive messages from the Service Worker
49 | listenToSW() {
50 | window.navigator.serviceWorker.onmessage = (event) => {
51 | if (event.data.command === 'timingsPlz') {
52 | // The Service Workers asks for resource timings
53 | const timings = this.readLatestResourceTimings();
54 |
55 | if (timings.length > 0) {
56 | this.sendResourceTimings(timings);
57 | }
58 | } else if (event.data.command === 'stats') {
59 | // The Service Workers sends the latest stats
60 | this.bandwidth = event.data.bandwidth;
61 | this.rtt = event.data.rtt;
62 | }
63 | };
64 | }
65 |
66 | // Gathers the latest ResourceTimings and sends them to SW
67 | sendResourceTimings(simplifiedTimings) {
68 | try {
69 | navigator.serviceWorker.controller.postMessage({
70 | 'command': 'eatThat',
71 | 'timings': simplifiedTimings
72 | });
73 | } catch(error) {}
74 | }
75 |
76 | // Gathers the ResourceTimings from the API
77 | readLatestResourceTimings() {
78 |
79 | // Not compatible browsers
80 | if (!window.performance || !window.performance.getEntriesByType || !window.performance.timing) {
81 | return [];
82 | }
83 |
84 | let timings = [];
85 |
86 | if (!this.firstRequestSent) {
87 |
88 | // Save this for later
89 | this.navigationStart = window.performance.timing.navigationStart;
90 |
91 | // The first HTML resource is as intersting as the others... maybe even more!
92 | timings.push({
93 | name: window.location.href,
94 | transferSize: window.performance.timing.transferSize,
95 | domainLookupStart: window.performance.timing.domainLookupStart,
96 | domainLookupEnd: window.performance.timing.domainLookupEnd,
97 | connectStart: window.performance.timing.connectStart,
98 | connectEnd: window.performance.timing.connectEnd,
99 | requestStart: window.performance.timing.requestStart,
100 | responseStart: window.performance.timing.responseStart,
101 | responseEnd: window.performance.timing.responseEnd
102 | });
103 |
104 | this.firstRequestSent = true;
105 | }
106 |
107 | window.performance.getEntriesByType('resource').forEach((timing) => {
108 | timings.push({
109 | name: timing.name,
110 | transferSize: timing.transferSize,
111 | domainLookupStart: Math.round(this.navigationStart + timing.domainLookupStart),
112 | domainLookupEnd: Math.round(this.navigationStart + timing.domainLookupEnd),
113 | connectStart: Math.round(this.navigationStart + timing.connectStart),
114 | connectEnd: Math.round(this.navigationStart + timing.connectEnd),
115 | secureConnectionStart: Math.round(this.navigationStart + timing.secureConnectionStart),
116 | requestStart: Math.round(this.navigationStart + timing.requestStart),
117 | responseStart: Math.round(this.navigationStart + timing.responseStart),
118 | responseEnd: Math.round(this.navigationStart + timing.responseEnd)
119 | });
120 | });
121 |
122 | // Now lets clear resourceTimings
123 | window.performance.clearResourceTimings();
124 | window.performance.setResourceTimingBufferSize(200);
125 |
126 | // TODO: add an option to avoid clearing ResourceTimings...
127 | // ... some other scripts might need them!
128 |
129 | return timings;
130 | }
131 |
132 | // On the SW's side, stats are saved in IndexedDB. Here we have access to LocalStorage.
133 | storageIO() {
134 | // When leaving the page, save stats into LocalStorage for faster ignition
135 | window.addEventListener('unload', () => {
136 | if (this.bandwidth || this.rtt) {
137 | window.localStorage.setItem('howslow', this.bandwidth + ',' + this.rtt);
138 | }
139 | });
140 |
141 | // And when arriving on the page, retrieve stats
142 | let stats = window.localStorage.getItem('howslow');
143 | if (stats) {
144 | stats = stats.split(',');
145 | this.bandwidth = stats[0] || undefined;
146 | this.rtt = stats[1] || undefined;
147 | }
148 | }
149 | }
--------------------------------------------------------------------------------
/howSlowForSW.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | /* ----- ↑ Write your own service workers rules above this line ↑ ----- */
4 | /* ----- ↓ Change below this line at your own risks ↓ -------------------------------- */
5 |
6 | // Service Worker initialization
7 | self.addEventListener('install', () => {
8 | self.skipWaiting(); // Activate worker immediately
9 | });
10 |
11 | self.addEventListener('activate', () => {
12 | if (self.clients && self.clients.claim) {
13 | // Make it available immediately
14 | self.clients.claim().then(() => {
15 | // The service worker is ready to work
16 |
17 | // The attached pages might already have some resource timings available.
18 | // Let's ask!
19 | howslow.askTimingsToClients();
20 | });
21 | }
22 | });
23 |
24 | // Intercept requests
25 | self.addEventListener('fetch', (event) => {
26 |
27 | if (typeof urlBlockingHook === 'function' && urlBlockingHook(event.request.url) === true) {
28 | event.respondWith(new Response('', {
29 | status: 446,
30 | statusText: 'Blocked by Service Worker'
31 | }));
32 | return;
33 | }
34 |
35 | let modifiedUrl = (typeof urlRewritingHook === 'function') ? urlRewritingHook(event.request.url) : null;
36 | let options = {};
37 |
38 | if (modifiedUrl) {
39 | // Add credentials to the request otherwise the fetch method opens a new connection
40 | options.credentials = 'include';
41 | }
42 |
43 | event.respondWith(
44 | fetch((modifiedUrl || event.request), options)
45 | .then(function(response) {
46 | // Save the content-length header
47 | howslow.addContentLength(response.url, response);
48 | return response;
49 | })
50 | );
51 | });
52 |
53 |
54 | class HowSlowForSW {
55 |
56 | constructor() {
57 | this.allTimings = [];
58 | this.allContentLengths = [];
59 | this.allIntervals = [];
60 | this.INTERVAL_DURATION = 25; // in milliseconds
61 |
62 | // That's our supposed service worker initialization time
63 | // TODO: replace by the reference time used inside the SW's Resource Timings
64 | // (I have no idea how to find it)
65 | this.epoch = Date.now();
66 |
67 | // Start the ticker
68 | this.tick();
69 |
70 | // Listen to the broadcast responses
71 | self.addEventListener('message', (event) => {
72 | if (event.data.command === 'eatThat') {
73 | // Some new timings just arrived from a page
74 | event.data.timings.forEach((timing) => {
75 | this.addOneTiming(timing)
76 | });
77 | }
78 | });
79 |
80 | this.initDatabase();
81 | }
82 |
83 | // Every second:
84 | tick() {
85 | // Do that...
86 | this.refreshStats();
87 | this.sendStatsToClients();
88 |
89 | // ... and repeat
90 | setTimeout(() => {
91 | this.tick();
92 | }, 1000);
93 | }
94 |
95 | getBandwidth() {
96 | if (this.bandwidth) {
97 | return this.bandwidth;
98 | }
99 |
100 | // If we couldn't estimate bandwidth yet, but we've got a record in database
101 | // We serve the saved bandwidth
102 | if (!this.connectionTypeFromDatabase
103 | || !self.navigator.connection
104 | || !self.navigator.connection.type
105 | || this.connectionTypeFromDatabase === self.navigator.connection.type) {
106 |
107 | return this.bandwidthFromDatabase;
108 | }
109 |
110 | return undefined;
111 | }
112 |
113 | getRTT() {
114 | if (this.rtt) {
115 | return this.rtt;
116 | }
117 |
118 | // If we couldn't estimate bandwidth yet, but we've got a record in database
119 | // We serve the saved bandwidth
120 | if (!this.connectionTypeFromDatabase
121 | || !self.navigator.connection
122 | || !self.navigator.connection.type
123 | || this.connectionTypeFromDatabase === self.navigator.connection.type) {
124 |
125 | return this.rttFromDatabase;
126 | }
127 |
128 | return undefined;
129 | }
130 |
131 | // Updates bandwidth & rtt
132 | refreshStats() {
133 |
134 | // Update the data from resource timings
135 | this.refreshTimings();
136 |
137 | // Use the data to estimate bandwidth
138 | this.bandwidth = this.estimateBandwidth();
139 | this.rtt = this.estimateRTT();
140 |
141 | // If the bandwith or the RTT were correctly estimated,
142 | // we save them to database
143 | if (this.bandwidth || this.rtt) {
144 | this.saveStats();
145 | }
146 | }
147 |
148 | // Collects the latest resource timings
149 | refreshTimings() {
150 |
151 | if (self.performance && self.performance.getEntriesByType) {
152 | // If the Service Worker has access to the Resource Timing API,
153 | // It's easy, we just read it.
154 |
155 | self.performance.getEntriesByType('resource').forEach((timing) => {
156 | this.addOneTiming(this.simplifyTimingObject(timing));
157 | });
158 |
159 | // Then we empty the history
160 | self.performance.clearResourceTimings();
161 |
162 | // "The clearResourceTimings() method removes all performance entries [...] and sets
163 | // the size of the performance data buffer to zero. To set the size of the browser's
164 | // performance data buffer, use the Performance.setResourceTimingBufferSize() method."
165 | self.performance.setResourceTimingBufferSize(200);
166 | }
167 |
168 | // TODO : the "else" part, when the Service Workers doesn't have access to
169 | // the Resource Timing API (Microsoft Edge)
170 | }
171 |
172 | // Sends a request to all clients for their resource timings.
173 | askTimingsToClients() {
174 | this.sendMessageToAllClients({
175 | command: 'timingsPlz'
176 | });
177 | }
178 |
179 | // Send bandwidth and RTT to all clients
180 | sendStatsToClients() {
181 | this.sendMessageToAllClients({
182 | command: 'stats',
183 | bandwidth: this.getBandwidth(),
184 | rtt: this.getRTT()
185 | });
186 | }
187 |
188 | // Sends a message to all clients
189 | sendMessageToAllClients(json) {
190 | self.clients.matchAll()
191 | .then((clients) => {
192 | clients.forEach((client) => {
193 | client.postMessage(json);
194 | });
195 | });
196 | }
197 |
198 | // Saves one timing in the allTimings list
199 | // only if it doesn't look like it comes from the browser's cache
200 | addOneTiming(timing) {
201 |
202 | // If we don't have the transfer size (Safari & Edge don't provide it)
203 | // than let's try to read it from the Content-Length headers.
204 | if (!timing.transferSize) {
205 | timing.transferSize = this.findContentLength(timing.name);
206 | }
207 |
208 | const time = timing.responseEnd - timing.responseStart;
209 |
210 | // If the transfer is ridiculously fast (> 200Mbps), then it most probably comes
211 | // from browser cache and timing is not reliable.
212 | if (time > 0 && timing.transferSize > 0 && timing.transferSize / time < 26214) {
213 | this.allTimings.push(timing);
214 | this.splitTimingIntoIntervals(timing);
215 | }
216 | }
217 |
218 | // To be able to estimate the bandwidth, we split this resource transferSize into
219 | // time intervals and add them to our timeline.
220 | splitTimingIntoIntervals(timing) {
221 | let startingBlock = Math.floor((timing.responseStart - this.epoch) / this.INTERVAL_DURATION);
222 | let endingBlock = Math.floor((timing.responseEnd - this.epoch) / this.INTERVAL_DURATION);
223 | let bytesPerBlock = timing.transferSize / ((endingBlock - startingBlock + 1));
224 |
225 | for (var i = startingBlock; i <= endingBlock; i++) {
226 | this.allIntervals[i] = (this.allIntervals[i] || 0) + bytesPerBlock;
227 | }
228 | }
229 |
230 | // What a good idea we had, to save the Content-Length headers!
231 | // Because we need one.
232 | findContentLength(url) {
233 | for (var i = this.allContentLengths.length - 1; i >= 0; i--) {
234 | if (this.allContentLengths[i].url === url) {
235 | return parseInt(this.allContentLengths[i].size, 10);
236 | }
237 | }
238 | }
239 |
240 | // Saves the content-length data from a fetched response header
241 | addContentLength(url, response) {
242 | if (response.type !== 'opaque' && response.headers.has('content-length')) {
243 | this.allContentLengths.push({
244 | url: url,
245 | size: response.headers.get('content-length')
246 | });
247 | }
248 | }
249 |
250 | // Reads timings and estimates bandwidth
251 | estimateBandwidth() {
252 |
253 | // Let's estimate the bandwidth for some different periods of times (in seconds)
254 | const ages = [20, 60, 300, 86400]; // 20s, 1m, 5m, 1d
255 | const bandwidths = ages.map((bw) => this.estimateBandwidthForAPeriod(bw));
256 |
257 | let result = this.averageWithWeight(bandwidths);
258 |
259 | // Always cap bandwidth with the theorical max speed of underlying network
260 | // (when the Network Information API is available, of course)
261 | // It makes the library more reactive when, for exemple, the user
262 | // sudenly looses its 3G signal and gets 2G instead.
263 | result = Math.min(result, this.getDownlinkMax());
264 |
265 | return Math.round(result);
266 | }
267 |
268 | // Estimates bandwidth for the last given number of seconds
269 | estimateBandwidthForAPeriod(numberOfSeconds) {
270 |
271 | // Now, minus the number of minutes
272 | const from = Date.now() - this.epoch - (numberOfSeconds * 1000);
273 |
274 | // Retrieves corresponding cells in the timeline array
275 | const newArray = this.allIntervals.slice(from / this.INTERVAL_DURATION);
276 |
277 | if (newArray.length === 0) {
278 | return undefined;
279 | }
280 |
281 | // Sums up the transfered size in this duration
282 | const transferedSize = newArray.reduce((a, b) => a + b);
283 |
284 | // Skip estimating bandwidth if too few kilobytes were collected
285 | if (transferedSize < 51200) {
286 | return undefined;
287 | }
288 |
289 | // Now let's use the 90th percentile of all values
290 | // From my different tests, that percentile provides good results
291 | const nineteenthPercentile = this.percentile(newArray, .9);
292 |
293 | // Convert bytes per (this.INTERVAL_DURATION)ms to kilobytes per second (kilobytes, not kilobits!)
294 | const mbps = nineteenthPercentile * 1000 / this.INTERVAL_DURATION / 1024;
295 |
296 | return mbps;
297 | }
298 |
299 | // Reads timings and estimates Round Trip Time
300 | estimateRTT() {
301 | // Same as for bandwidth, we start by estimating the RTT on several periods of time
302 | const ages = [20, 60, 300, 86400]; // 20s, 1m, 5m, 1d
303 | const rtts = ages.map((bw) => this.estimateRTTForAPeriod(bw));
304 |
305 | return Math.round(this.averageWithWeight(rtts));
306 | }
307 |
308 | // Estimates RTT for the last given number of seconds
309 | estimateRTTForAPeriod(numberOfSeconds) {
310 |
311 | // Now, minus the number of minutes
312 | const from = Date.now() - (numberOfSeconds * 1000);
313 |
314 | let pings = this.allTimings.filter(timing => {
315 | return timing.responseEnd >= from;
316 | }).map(timing => {
317 | // The estimated RTT for one request is an average of:
318 | // DNS lookup time + First connection + SSL handshake + Time to First Byte
319 | // in milliseconds.
320 | //
321 | // Note: we can't rely on timing.secureConnectionStart because IE doesn't provide it.
322 | // But we're always on HTTPS, so let's just count TCP connection as two roundtrips.
323 | //
324 | const dns = timing.domainLookupEnd - timing.domainLookupStart;
325 | const tcp = timing.connectEnd - timing.connectStart;
326 | const ttfb = timing.responseStart - timing.requestStart;
327 |
328 | // Let's consider that any timing under 10ms is not valuable
329 | const roundtripsCount = (dns > 10) + ((tcp > 10) * 2) + (ttfb > 10);
330 | return roundtripsCount ? Math.round((dns + tcp + ttfb) / roundtripsCount) : null;
331 | });
332 |
333 | // Skip estimating RTT if too few requests were analyzed
334 | if (pings.length < 3) {
335 | return undefined;
336 | }
337 |
338 | // Let's use the 20th percentile here, to eliminate servers' slowness
339 | return this.percentile(pings, .2);
340 | }
341 |
342 | // Returns the value at a given percentile in a numeric array.
343 | // Not very accurate, but accurate enough for our needs.
344 | percentile(arr, p) {
345 |
346 | // Remove undefineds and transfer to a new array
347 | let newArray = arr.filter((cell) => cell !== undefined);
348 |
349 | // Fail if there are no results
350 | if (newArray.length === 0) {
351 | return undefined;
352 | }
353 |
354 | newArray.sort((a, b) => a - b);
355 |
356 | return newArray[Math.floor(newArray.length * p)];
357 | }
358 |
359 | // Returns the average of the array, but gives much more weight to the first values
360 | averageWithWeight(arr) {
361 | let total = 0;
362 | let totalWeights = 0;
363 |
364 | for (var i = 0; i < arr.length; i++) {
365 | if (arr[i] !== undefined) {
366 |
367 | let weight = 1 / Math.pow(i + 1, 3);
368 | // With that formula:
369 | // - the weight of the 1st value is 1
370 | // - of the 2nd value is 1/8
371 | // - of the 3rd value is 1/27
372 | // - of the 4th value is 1/64
373 | // ...
374 |
375 | total += arr[i] * weight;
376 | totalWeights += weight;
377 | }
378 | }
379 |
380 | if (totalWeights === 0) {
381 | return undefined;
382 | }
383 |
384 | return total / totalWeights;
385 | }
386 |
387 | simplifyTimingObject(timing) {
388 | return {
389 | name: timing.name,
390 | transferSize: timing.transferSize,
391 | domainLookupStart: Math.round(this.epoch + timing.domainLookupStart),
392 | domainLookupEnd: Math.round(this.epoch + timing.domainLookupEnd),
393 | connectStart: Math.round(this.epoch + timing.connectStart),
394 | connectEnd: Math.round(this.epoch + timing.connectEnd),
395 | requestStart: Math.round(this.epoch + timing.requestStart),
396 | responseStart: Math.round(this.epoch + timing.responseStart),
397 | responseEnd: Math.round(this.epoch + timing.responseEnd)
398 | };
399 | }
400 |
401 | getDownlinkMax() {
402 | if (self.navigator.connection && self.navigator.connection.downlinkMax > 0) {
403 | return self.navigator.connection.downlinkMax * 256; // convert Mbps to KBps
404 | }
405 | return Infinity;
406 | }
407 |
408 | initDatabase() {
409 | // Open database connection
410 | let dbPromise = self.indexedDB.open('howslow', 1)
411 |
412 | dbPromise.onupgradeneeded = (event) => {
413 | if (!event.target.result.objectStoreNames.contains('bw')) {
414 | event.target.result.createObjectStore('bw');
415 | }
416 | };
417 |
418 | dbPromise.onsuccess = (event) => {
419 | this.database = event.target.result;
420 | this.retrieveStats();
421 | };
422 |
423 | // Not handling DB errors cause it's ok, we can still work without DB
424 | }
425 |
426 | // Saves bandwidth & RTT to IndexedDB
427 | saveStats() {
428 | let object = {
429 | bandwidth: this.bandwidth,
430 | rtt: this.rtt,
431 | connectionType: self.navigator.connection && self.navigator.connection.type
432 | };
433 |
434 | try {
435 | this.database.transaction('bw', 'readwrite').objectStore('bw').put(object, 1);
436 | } catch(error) {
437 | // Silent error
438 | }
439 | }
440 |
441 | // Reads the latest known bandwidth & RTT from IndexedDB
442 | retrieveStats() {
443 | try {
444 | this.database.transaction('bw', 'readonly').objectStore('bw').get(1).onsuccess = (event) => {
445 | if (event.target.result) {
446 | this.bandwidthFromDatabase = event.target.result.bandwidth || undefined;
447 | this.rttFromDatabase = event.target.result.rtt || undefined;
448 | this.connectionTypeFromDatabase = event.target.result.connectionType;
449 | }
450 | };
451 | } catch(error) {
452 | // Silent error
453 | }
454 | }
455 | }
456 |
457 | // Let's go!
458 | self.howslow = new HowSlowForSW();
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "HowSlow",
3 | "version": "0.0.1",
4 | "description": "Bandwidth and RTT estimation script",
5 | "main": "index.js",
6 | "scripts": {
7 | "build-demo": "cat docs/demo/myUrlRewritingFunction.js howSlowForSW.js > docs/demo/forSW.js; babel howSlowForPage.js --out-file docs/demo/forPage.js;"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/fasterize/HowSlow.git"
12 | },
13 | "keywords": [
14 | "webperf",
15 | "performance",
16 | "speed",
17 | "slowness",
18 | "detection",
19 | "bandwidth"
20 | ],
21 | "author": "Fasterize",
22 | "license": "MIT",
23 | "bugs": {
24 | "url": "https://github.com/fasterize/HowSlow/issues"
25 | },
26 | "homepage": "https://github.com/fasterize/HowSlow#readme",
27 | "devDependencies": {
28 | "babel-cli": "6.26.0",
29 | "babel-preset-es2015": "6.24.1"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------