blob: 2c4dcafd41e66d52af3fc2b2efc3b9ef2877789d [file] [log] [blame]
Sergiusz Bazanskia51df9c2019-07-13 16:07:13 +02001/*
2 HTML5 Speedtest - Main
3 by Federico Dossena
4 https://github.com/adolfintel/speedtest/
5 GNU LGPLv3 License
6*/
7
8/*
9 This is the main interface between your webpage and the speedtest.
10 It hides the speedtest web worker to the page, and provides many convenient functions to control the test.
11
12 The best way to learn how to use this is to look at the basic example, but here's some documentation.
13
14 To initialize the test, create a new Speedtest object:
15 var s=new Speedtest();
16 Now you can think of this as a finite state machine. These are the states (use getState() to see them):
17 - 0: here you can change the speedtest settings (such as test duration) with the setParameter("parameter",value) method. From here you can either start the test using start() (goes to state 3) or you can add multiple test points using addTestPoint(server) or addTestPoints(serverList) (goes to state 1). Additionally, this is the perfect moment to set up callbacks for the onupdate(data) and onend(aborted) events.
18 - 1: here you can add test points. You only need to do this if you want to use multiple test points.
19 A server is defined as an object like this:
20 {
21 name: "User friendly name",
22 server:"http://yourBackend.com/", <---- URL to your server. You can specify http:// or https://. If your server supports both, just write // without the protocol
23 dlURL:"garbage.php" <----- path to garbage.php or its replacement on the server
24 ulURL:"empty.php" <----- path to empty.php or its replacement on the server
25 pingURL:"empty.php" <----- path to empty.php or its replacement on the server. This is used to ping the server by this selector
26 getIpURL:"getIP.php" <----- path to getIP.php or its replacement on the server
27 }
28 While in state 1, you can only add test points, you cannot change the test settings. When you're done, use selectServer(callback) to select the test point with the lowest ping. This is asynchronous, when it's done, it will call your callback function and move to state 2. Calling setSelectedServer(server) will manually select a server and move to state 2.
29 - 2: test point selected, ready to start the test. Use start() to begin, this will move to state 3
30 - 3: test running. Here, your onupdate event calback will be called periodically, with data coming from the worker about speed and progress. A data object will be passed to your onupdate function, with the following items:
31 - dlStatus: download speed in mbps
32 - ulStatus: upload speed in mbps
33 - pingStatus: ping in ms
34 - jitterStatus: jitter in ms
35 - dlProgress: progress of the download test as a float 0-1
36 - ulProgress: progress of the upload test as a float 0-1
37 - pingProgress: progress of the ping/jitter test as a float 0-1
38 - testState: state of the test (-1=not started, 0=starting, 1=download test, 2=ping+jitter test, 3=upload test, 4=finished, 5=aborted)
39 - clientIp: IP address of the client performing the test (and optionally ISP and distance)
40 At the end of the test, the onend function will be called, with a boolean specifying whether the test was aborted or if it ended normally.
41 The test can be aborted at any time with abort().
42 At the end of the test, it will move to state 4
43 - 4: test finished. You can run it again by calling start() if you want.
44 */
45
46function Speedtest() {
47 this._serverList = []; //when using multiple points of test, this is a list of test points
48 this._selectedServer = null; //when using multiple points of test, this is the selected server
49 this._settings = {}; //settings for the speedtest worker
50 this._state = 0; //0=adding settings, 1=adding servers, 2=server selection done, 3=test running, 4=done
51 console.log(
52 "HTML5 Speedtest by Federico Dossena v5.0 - https://github.com/adolfintel/speedtest"
53 );
54}
55
56Speedtest.prototype = {
57 constructor: Speedtest,
58 /**
59 * Returns the state of the test: 0=adding settings, 1=adding servers, 2=server selection done, 3=test running, 4=done
60 */
61 getState: function() {
62 return this._state;
63 },
64 /**
65 * Change one of the test settings from their defaults.
66 * - parameter: string with the name of the parameter that you want to set
67 * - value: new value for the parameter
68 *
69 * Invalid values or nonexistant parameters will be ignored by the speedtest worker.
70 */
71 setParameter: function(parameter, value) {
72 if (this._state != 0)
73 throw "You cannot change the test settings after adding server or starting the test";
74 this._settings[parameter] = value;
75 },
76 /**
77 * Used internally to check if a server object contains all the required elements.
78 * Also fixes the server URL if needed.
79 */
80 _checkServerDefinition: function(server) {
81 try {
82 if (typeof server.name !== "string")
83 throw "Name string missing from server definition (name)";
84 if (typeof server.server !== "string")
85 throw "Server address string missing from server definition (server)";
86 if (server.server.charAt(server.server.length - 1) != "/")
87 server.server += "/";
88 if (server.server.indexOf("//") == 0)
89 server.server = location.protocol + server.server;
90 if (typeof server.dlURL !== "string")
91 throw "Download URL string missing from server definition (dlURL)";
92 if (typeof server.ulURL !== "string")
93 throw "Upload URL string missing from server definition (ulURL)";
94 if (typeof server.pingURL !== "string")
95 throw "Ping URL string missing from server definition (pingURL)";
96 if (typeof server.getIpURL !== "string")
97 throw "GetIP URL string missing from server definition (getIpURL)";
98 } catch (e) {
99 throw "Invalid server definition";
100 }
101 },
102 /**
103 * Add a test point (multiple points of test)
104 * server: the server to be added as an object. Must contain the following elements:
105 * {
106 * name: "User friendly name",
107 * server:"http://yourBackend.com/", URL to your server. You can specify http:// or https://. If your server supports both, just write // without the protocol
108 * dlURL:"garbage.php" path to garbage.php or its replacement on the server
109 * ulURL:"empty.php" path to empty.php or its replacement on the server
110 * pingURL:"empty.php" path to empty.php or its replacement on the server. This is used to ping the server by this selector
111 * getIpURL:"getIP.php" path to getIP.php or its replacement on the server
112 * }
113 */
114 addTestPoint: function(server) {
115 this._checkServerDefinition(server);
116 if (this._state == 0) this._state = 1;
117 if (this._state != 1) throw "You can't add a server after server selection";
118 this._settings.mpot = true;
119 this._serverList.push(server);
120 },
121 /**
122 * Same as addTestPoint, but you can pass an array of servers
123 */
124 addTestPoints: function(list) {
125 for (var i = 0; i < list.length; i++) this.addTestPoint(list[i]);
126 },
127 /**
128 * Returns the selected server (multiple points of test)
129 */
130 getSelectedServer: function() {
131 if (this._state < 2 || this._selectedServer == null)
132 throw "No server is selected";
133 return this._selectedServer;
134 },
135 /**
136 * Manually selects one of the test points (multiple points of test)
137 */
138 setSelectedServer: function(server) {
139 this._checkServerDefinition(server);
140 if (this._state == 3)
141 throw "You can't select a server while the test is running";
142 this._selectedServer = server;
143 this._state = 2;
144 },
145 /**
146 * Automatically selects a server from the list of added test points. The server with the lowest ping will be chosen. (multiple points of test)
147 * The process is asynchronous and the passed result callback function will be called when it's done, then the test can be started.
148 */
149 selectServer: function(result) {
150 if (this._state != 1) {
151 if (this._state == 0) throw "No test points added";
152 if (this._state == 2) throw "Server already selected";
153 if (this._state >= 3)
154 throw "You can't select a server while the test is running";
155 }
156 if (this._selectServerCalled) throw "selectServer already called"; else this._selectServerCalled=true;
157 /*this function goes through a list of servers. For each server, the ping is measured, then the server with the function result is called with the best server, or null if all the servers were down.
158 */
159 var select = function(serverList, result) {
160 //pings the specified URL, then calls the function result. Result will receive a parameter which is either the time it took to ping the URL, or -1 if something went wrong.
161 var PING_TIMEOUT = 2000;
162 var USE_PING_TIMEOUT = true; //will be disabled on unsupported browsers
163 if (/MSIE.(\d+\.\d+)/i.test(navigator.userAgent)) {
164 //IE11 doesn't support XHR timeout
165 USE_PING_TIMEOUT = false;
166 }
167 var ping = function(url, result) {
168 url += (url.match(/\?/) ? "&" : "?") + "cors=true";
169 var xhr = new XMLHttpRequest();
170 var t = new Date().getTime();
171 xhr.onload = function() {
172 if (xhr.responseText.length == 0) {
173 //we expect an empty response
174 var instspd = new Date().getTime() - t; //rough timing estimate
175 try {
176 //try to get more accurate timing using performance API
177 var p = performance.getEntriesByName(url);
178 p = p[p.length - 1];
179 var d = p.responseStart - p.requestStart;
180 if (d <= 0) d = p.duration;
181 if (d > 0 && d < instspd) instspd = d;
182 } catch (e) {}
183 result(instspd);
184 } else result(-1);
185 }.bind(this);
186 xhr.onerror = function() {
187 result(-1);
188 }.bind(this);
189 xhr.open("GET", url);
190 if (USE_PING_TIMEOUT) {
191 try {
192 xhr.timeout = PING_TIMEOUT;
193 xhr.ontimeout = xhr.onerror;
194 } catch (e) {}
195 }
196 xhr.send();
197 }.bind(this);
198
199 //this function repeatedly pings a server to get a good estimate of the ping. When it's done, it calls the done function without parameters. At the end of the execution, the server will have a new parameter called pingT, which is either the best ping we got from the server or -1 if something went wrong.
200 var PINGS = 3, //up to 3 pings are performed, unless the server is down...
201 SLOW_THRESHOLD = 500; //...or one of the pings is above this threshold
202 var checkServer = function(server, done) {
203 var i = 0;
204 server.pingT = -1;
205 if (server.server.indexOf(location.protocol) == -1) done();
206 else {
207 var nextPing = function() {
208 if (i++ == PINGS) {
209 done();
210 return;
211 }
212 ping(
213 server.server + server.pingURL,
214 function(t) {
215 if (t >= 0) {
216 if (t < server.pingT || server.pingT == -1) server.pingT = t;
217 if (t < SLOW_THRESHOLD) nextPing();
218 else done();
219 } else done();
220 }.bind(this)
221 );
222 }.bind(this);
223 nextPing();
224 }
225 }.bind(this);
226 //check servers in list, one by one
227 var i = 0;
228 var done = function() {
229 var bestServer = null;
230 for (var i = 0; i < serverList.length; i++) {
231 if (
232 serverList[i].pingT != -1 &&
233 (bestServer == null || serverList[i].pingT < bestServer.pingT)
234 )
235 bestServer = serverList[i];
236 }
237 result(bestServer);
238 }.bind(this);
239 var nextServer = function() {
240 if (i == serverList.length) {
241 done();
242 return;
243 }
244 checkServer(serverList[i++], nextServer);
245 }.bind(this);
246 nextServer();
247 }.bind(this);
248
249 //parallel server selection
250 var CONCURRENCY = 6;
251 var serverLists = [];
252 for (var i = 0; i < CONCURRENCY; i++) {
253 serverLists[i] = [];
254 }
255 for (var i = 0; i < this._serverList.length; i++) {
256 serverLists[i % CONCURRENCY].push(this._serverList[i]);
257 }
258 var completed = 0;
259 var bestServer = null;
260 for (var i = 0; i < CONCURRENCY; i++) {
261 select(
262 serverLists[i],
263 function(server) {
264 if (server != null) {
265 if (bestServer == null || server.pingT < bestServer.pingT)
266 bestServer = server;
267 }
268 completed++;
269 if (completed == CONCURRENCY) {
270 this._selectedServer = bestServer;
271 this._state = 2;
272 if (result) result(bestServer);
273 }
274 }.bind(this)
275 );
276 }
277 },
278 /**
279 * Starts the test.
280 * During the test, the onupdate(data) callback function will be called periodically with data from the worker.
281 * At the end of the test, the onend(aborted) function will be called with a boolean telling you if the test was aborted or if it ended normally.
282 */
283 start: function() {
284 if (this._state == 3) throw "Test already running";
285 this.worker = new Worker("speedtest_worker.js?r=" + Math.random());
286 this.worker.onmessage = function(e) {
287 if (e.data === this._prevData) return;
288 else this._prevData = e.data;
289 var data = JSON.parse(e.data);
290 try {
291 if (this.onupdate) this.onupdate(data);
292 } catch (e) {
293 console.error("Speedtest onupdate event threw exception: " + e);
294 }
295 if (data.testState >= 4) {
296 try {
297 if (this.onend) this.onend(data.testState == 5);
298 } catch (e) {
299 console.error("Speedtest onend event threw exception: " + e);
300 }
301 clearInterval(this.updater);
302 this._state = 4;
303 }
304 }.bind(this);
305 this.updater = setInterval(
306 function() {
307 this.worker.postMessage("status");
308 }.bind(this),
309 200
310 );
311 if (this._state == 1)
312 throw "When using multiple points of test, you must call selectServer before starting the test";
313 if (this._state == 2) {
314 this._settings.url_dl =
315 this._selectedServer.server + this._selectedServer.dlURL;
316 this._settings.url_ul =
317 this._selectedServer.server + this._selectedServer.ulURL;
318 this._settings.url_ping =
319 this._selectedServer.server + this._selectedServer.pingURL;
320 this._settings.url_getIp =
321 this._selectedServer.server + this._selectedServer.getIpURL;
322 if (typeof this._settings.telemetry_extra !== "undefined") {
323 this._settings.telemetry_extra = JSON.stringify({
324 server: this._selectedServer.name,
325 extra: this._settings.telemetry_extra
326 });
327 } else
328 this._settings.telemetry_extra = JSON.stringify({
329 server: this._selectedServer.name
330 });
331 }
332 this._state = 3;
333 this.worker.postMessage("start " + JSON.stringify(this._settings));
334 },
335 /**
336 * Aborts the test while it's running.
337 */
338 abort: function() {
339 if (this._state < 3) throw "You cannot abort a test that's not started yet";
340 if (this._state < 4) this.worker.postMessage("abort");
341 }
342};