speedtest/stability_worker.js
Tom Hudson 7839a4331c Add internet stability test feature
Add a prolonged ping-based stability test with real-time canvas chart,
stats (avg/min/max/jitter/packet loss), stability rating, external ping
targets, CSV export, and Docker support. Link from main page to stability test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-16 18:47:08 +02:00

216 lines
5.6 KiB
JavaScript

/*
LibreSpeed - Stability Test Worker
https://github.com/librespeed/speedtest/
GNU LGPLv3 License
*/
// data reported to main thread
let testState = -1; // -1=idle, 0=starting, 1=running, 4=finished, 5=aborted
let currentPing = 0;
let avgPing = 0;
let minPing = -1;
let maxPing = 0;
let jitter = 0;
let packetLoss = 0;
let elapsed = 0;
let progress = 0;
let pingData = []; // all ping data points {t: elapsedMs, ping: ms, lost: bool}
let lastReportedIndex = 0; // for delta delivery
let totalSamples = 0;
let failedSamples = 0;
let pingSum = 0;
let settings = {
url_ping: "backend/empty.php",
url_ping_external: "", // external URL to ping (uses fetch no-cors, e.g. "https://www.google.com/generate_204")
duration: 60, // seconds
ping_allowPerformanceApi: true,
mpot: false
};
let xhr = null;
let startTime = 0;
let prevInstspd = 0;
let aborted = false;
function url_sep(url) {
return url.match(/\?/) ? "&" : "?";
}
this.addEventListener("message", function (e) {
const params = e.data.split(" ");
if (params[0] === "status") {
// return current state with delta ping data
const newData = pingData.slice(lastReportedIndex);
lastReportedIndex = pingData.length;
postMessage(
JSON.stringify({
testState: testState,
currentPing: currentPing,
avgPing: avgPing,
minPing: minPing,
maxPing: maxPing,
jitter: jitter,
packetLoss: packetLoss,
elapsed: elapsed,
duration: settings.duration,
progress: progress,
pingData: newData,
totalSamples: totalSamples,
failedSamples: failedSamples
})
);
}
if (params[0] === "start" && testState === -1) {
testState = 0;
try {
let s = {};
try {
const ss = e.data.substring(6);
if (ss) s = JSON.parse(ss);
} catch (e) {
console.warn("Error parsing settings JSON");
}
for (let key in s) {
if (typeof settings[key] !== "undefined") settings[key] = s[key];
}
} catch (e) {
console.warn("Error applying settings: " + e);
}
// start the stability test
aborted = false;
startTime = new Date().getTime();
testState = 1;
doPing();
}
if (params[0] === "abort") {
if (testState >= 4) return;
aborted = true;
testState = 5;
if (xhr) {
try {
xhr.abort();
} catch (e) {}
}
}
});
function recordPing(instspd) {
// guard against 0ms pings
if (instspd < 1) instspd = prevInstspd;
if (instspd < 1) instspd = 1;
totalSamples++;
currentPing = instspd;
pingSum += instspd;
avgPing = parseFloat((pingSum / (totalSamples - failedSamples)).toFixed(2));
if (minPing === -1 || instspd < minPing) minPing = instspd;
if (instspd > maxPing) maxPing = instspd;
// jitter calculation (same weighted formula as speedtest_worker.js)
if (totalSamples > 1 && prevInstspd > 0) {
const instjitter = Math.abs(instspd - prevInstspd);
if (totalSamples === 2) {
jitter = instjitter;
} else {
jitter = instjitter > jitter ? jitter * 0.3 + instjitter * 0.7 : jitter * 0.8 + instjitter * 0.2;
}
}
prevInstspd = instspd;
// packet loss
packetLoss = totalSamples > 0 ? parseFloat(((failedSamples / totalSamples) * 100).toFixed(2)) : 0;
// record data point
const now = new Date().getTime();
elapsed = (now - startTime) / 1000;
pingData.push({ t: elapsed, ping: parseFloat(instspd.toFixed(2)), lost: false });
}
function recordLoss() {
const now = new Date().getTime();
totalSamples++;
failedSamples++;
packetLoss = parseFloat(((failedSamples / totalSamples) * 100).toFixed(2));
elapsed = (now - startTime) / 1000;
pingData.push({ t: elapsed, ping: 0, lost: true });
}
function doPing() {
if (aborted || testState >= 4) return;
// check if duration exceeded
const now = new Date().getTime();
elapsed = (now - startTime) / 1000;
progress = Math.min(1, elapsed / settings.duration);
if (elapsed >= settings.duration) {
testState = 4;
progress = 1;
return;
}
// external ping mode: use fetch with no-cors
if (settings.url_ping_external) {
doPingExternal();
return;
}
const prevT = new Date().getTime();
xhr = new XMLHttpRequest();
xhr.onload = function () {
if (aborted || testState >= 4) return;
const now = new Date().getTime();
let instspd = now - prevT;
if (settings.ping_allowPerformanceApi) {
try {
let p = performance.getEntries();
p = p[p.length - 1];
let d = p.responseStart - p.requestStart;
if (d <= 0) d = p.duration;
if (d > 0 && d < instspd) instspd = d;
} catch (e) {
// Performance API not available, use estimate
}
}
recordPing(instspd);
doPing();
};
xhr.onerror = function () {
if (aborted || testState >= 4) return;
recordLoss();
doPing();
};
xhr.ontimeout = xhr.onerror;
xhr.open(
"GET",
settings.url_ping + url_sep(settings.url_ping) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(),
true
);
try {
xhr.timeout = 5000;
} catch (e) {}
xhr.send();
}
// ping an external host using fetch with no-cors (opaque response, but timing still works)
function doPingExternal() {
const prevT = new Date().getTime();
const url =
settings.url_ping_external + (settings.url_ping_external.indexOf("?") >= 0 ? "&" : "?") + "r=" + Math.random();
fetch(url, { mode: "no-cors", cache: "no-store" })
.then(function () {
if (aborted || testState >= 4) return;
const instspd = new Date().getTime() - prevT;
recordPing(instspd);
doPing();
})
.catch(function () {
if (aborted || testState >= 4) return;
recordLoss();
doPing();
});
}