/* ============================================================ bnr_speedtest_v3.js — 並列・耐障害 測定エンジン(テスト版) - 下り:複数 fetch ストリームを同時に流し、合算スループットを時間ベースで測定 - 上り:複数 XHR POST を同時に流し、合算スループットを測定 - 死んだ/無反応サーバは streamTimeout で切り捨て、生存ストリームだけで完走 - grace(スロースタート区間)を除外して steady-state を測る 使い方: BnrSpeedV3.runDownload(p=>{...}).then(r=>r.mbps) BnrSpeedV3.runUpload(p=>{...}).then(r=>r.mbps) ============================================================ */ var BnrSpeedV3 = (function () { "use strict"; var CFG = { downServers: [ 'https://bnr-speed.n-wakae.workers.dev/down', // Cloudflare(最速・優先) 'https://www.musen-lan.com/speed/data/dat/down_v3.php', // さくら(フォールバック) 'https://suites.musen-lan.com/speed/data/dat/down_v3.php' // WebARENA(フォールバック) ], upServers: [ 'https://bnr-speed.n-wakae.workers.dev/up', // Cloudflare(最速・優先) 'https://www.musen-lan.com/speed/data/dat/up.php', // さくら(フォールバック) 'https://suites.musen-lan.com/speed/data/dat/up.php' // WebARENA(フォールバック) ], streams: 6, // 並列本数 duration: 8000, // 測定時間(ms) grace: 800, // 最初の除外時間(ms)=スロースタート streamTimeout: 3000, // 無反応サーバを見限る時間(ms) upChunk: 6 * 1024 * 1024, // 上り1回の送信サイズ(6MB) ※CF body上限100MB内・PHP fallbackのpost_max_size(8M)内 pingTargets: [ // Ping(レイテンシ)を測る拠点(複数・耐障害) { label: 'Cloudflare', url: 'https://bnr-speed.n-wakae.workers.dev/' }, { label: 'さくら', url: 'https://www.musen-lan.com/speed/data/dat/ping.php' }, { label: 'WebARENA', url: 'https://suites.musen-lan.com/speed/data/dat/ping.php' } ], pingCount: 12, // 各拠点の計測回数(先頭ウォームアップ2回は別途除外) pingTimeout: 1500 // 1回の応答待ち上限(ms)=無応答拠点はここで打ち切り(固まり防止) }; function bust(u) { return u + (u.indexOf('?') < 0 ? '?' : '&') + 't=' + Date.now() + '_' + Math.random().toString(36).slice(2); } function randomBlob(size) { var buf = new Uint8Array(size); for (var o = 0; o < size; o += 65536) { crypto.getRandomValues(buf.subarray(o, Math.min(o + 65536, size))); } return new Blob([buf], { type: 'application/octet-stream' }); } // ---------- 下り ---------- function runDownload(onProgress) { return new Promise(function (resolve) { var start = performance.now(); var measureStart = null; var total = 0, measured = 0, stopped = false, everData = false; var controllers = []; function streamWorker(idx) { // このストリームはサーバを順に試し、生きてる所からひたすら読む return (async function () { for (var attempt = 0; attempt < CFG.downServers.length && !stopped; attempt++) { var url = bust(CFG.downServers[attempt % CFG.downServers.length]); // 全streamがまず先頭(CF)→失敗時に次へフォールバック var ctrl = new AbortController(); controllers.push(ctrl); var gotData = false; var to = setTimeout(function () { if (!gotData) try { ctrl.abort(); } catch (e) {} }, CFG.streamTimeout); try { var res = await fetch(url, { signal: ctrl.signal, cache: 'no-store' }); if (!res.ok || !res.body) throw new Error('bad response'); var reader = res.body.getReader(); while (!stopped) { var r = await reader.read(); if (r.done) break; if (r.value && r.value.length) { if (!gotData) { gotData = true; everData = true; clearTimeout(to); } total += r.value.length; if (measureStart !== null) measured += r.value.length; } } clearTimeout(to); return; // 流れていた(stoppedで終了)=成功 } catch (e) { clearTimeout(to); if (gotData) return; // 一度でも流れた=このサーバ生存。終了。 // データ来ず=このサーバ死亡 → 次のサーバへ } } })(); } var graceTimer = setTimeout(function () { measureStart = performance.now(); }, CFG.grace); var progTimer = setInterval(function () { if (!onProgress) return; var now = performance.now(); var winMs = (measureStart !== null) ? (now - measureStart) : 0; var bps; if (winMs > 300 && measured > 0) { bps = measured * 8 / (winMs / 1000); // 計測区間の走行平均=最終値へ収束(窓が十分育ってから使う) } else { var s2 = (now - start) / 1000; // 序盤は開始からの平均(連続・0にならない=0.8sの瞬断防止) bps = s2 > 0 ? (total * 8 / s2) : 0; } onProgress(Math.min(100, (now - start) / CFG.duration * 100), bps); }, 100); var tasks = []; for (var i = 0; i < CFG.streams; i++) tasks.push(streamWorker(i)); setTimeout(function () { stopped = true; controllers.forEach(function (c) { try { c.abort(); } catch (e) {} }); clearTimeout(graceTimer); clearInterval(progTimer); Promise.allSettled(tasks).then(function () { var winSec = (CFG.duration - CFG.grace) / 1000; var mbps = (measured * 8) / winSec / 1e6; resolve({ mbps: mbps, bytes: measured, ok: (everData && measured > 0) }); }); }, CFG.duration); }); } // ---------- 上り ---------- function runUpload(onProgress) { return new Promise(function (resolve) { var start = performance.now(); var measureStart = null; var total = 0, measured = 0, stopped = false, everData = false; var xhrs = []; function streamUp(sIdx) { if (stopped) return; var url = bust(CFG.upServers[sIdx % CFG.upServers.length]); var xhr = new XMLHttpRequest(); xhrs.push(xhr); var last = 0, got = false; var to = setTimeout(function () { if (!got) try { xhr.abort(); } catch (e) {} }, CFG.streamTimeout); xhr.upload.onprogress = function (e) { got = true; everData = true; clearTimeout(to); var d = e.loaded - last; last = e.loaded; if (d > 0) { total += d; if (measureStart !== null) measured += d; } }; xhr.onloadend = function () { clearTimeout(to); if (stopped) return; if (got) { streamUp(sIdx); } // データが流れた → 同じサーバ(優先=CF)で継続 else { streamUp(sIdx + 1); } // 無反応 → 次のサーバへフォールバック }; try { xhr.open('POST', url); xhr.send(randomBlob(CFG.upChunk)); } catch (e) {} } var graceTimer = setTimeout(function () { measureStart = performance.now(); }, CFG.grace); var progTimer = setInterval(function () { if (!onProgress) return; var now = performance.now(); var winMs = (measureStart !== null) ? (now - measureStart) : 0; var bps; if (winMs > 300 && measured > 0) { bps = measured * 8 / (winMs / 1000); // 計測区間の走行平均=最終値へ収束(窓が十分育ってから使う) } else { var s2 = (now - start) / 1000; // 序盤は開始からの平均(連続・0にならない=0.8sの瞬断防止) bps = s2 > 0 ? (total * 8 / s2) : 0; } onProgress(Math.min(100, (now - start) / CFG.duration * 100), bps); }, 100); for (var i = 0; i < CFG.streams; i++) streamUp(0); // 全streamをまずCF(先頭)から setTimeout(function () { stopped = true; xhrs.forEach(function (x) { try { x.abort(); } catch (e) {} }); clearTimeout(graceTimer); clearInterval(progTimer); var winSec = (CFG.duration - CFG.grace) / 1000; resolve({ mbps: (measured * 8) / winSec / 1e6, bytes: measured, ok: (everData && measured > 0) }); }, CFG.duration); }); } // ---------- Ping(レイテンシ/Jitter・複数拠点・耐障害) ---------- // クライアント発信の小さなHTTP往復時間を測る=ICMP(ping)が遮断されても測定可能。 // 各拠点を並列・各リクエストにタイムアウト → 無応答拠点があっても固まらず、生存拠点だけで完了。 function runPing(onProgress) { return new Promise(function (resolve) { var targets = CFG.pingTargets; var WARMUP = 2; // 先頭は接続確立(TCP/TLS)ぶんなので除外 var perTotal = WARMUP + CFG.pingCount; var progressArr = targets.map(function () { return 0; }); var partials = targets.map(function (t) { return { label: t.label, ok: false, min: 0, median: 0, jitter: 0, count: 0 }; }); function statsOf(label, samples) { if (!samples.length) return { label: label, min: 0, median: 0, jitter: 0, count: 0, ok: false }; var s = samples.slice().sort(function (a, b) { return a - b; }); var median = s[Math.floor(s.length / 2)]; var j = 0; for (var k = 1; k < samples.length; k++) j += Math.abs(samples[k] - samples[k - 1]); return { label: label, min: s[0], median: median, jitter: samples.length > 1 ? j / (samples.length - 1) : 0, count: samples.length, ok: true }; } function report() { if (!onProgress) return; var prog = progressArr.reduce(function (a, b) { return a + b; }, 0) / targets.length; onProgress(Math.min(100, prog), partials.slice()); } function once(url) { // 1回の往復。タイムアウトで必ず決着(固まらない) return new Promise(function (res) { var ctrl = new AbortController(); var to = setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, CFG.pingTimeout); var t = performance.now(); fetch(bust(url), { cache: 'no-store', signal: ctrl.signal }).then(function (r) { var rtt = performance.now() - t; clearTimeout(to); if (r && typeof r.arrayBuffer === 'function') { r.arrayBuffer().catch(function () {}); } res(rtt); }).catch(function () { clearTimeout(to); res(null); }); // 失敗/タイムアウト=null }); } function runTarget(ti) { // 1拠点ぶんを順番に計測(他拠点とは並列) var t = targets[ti]; var samples = []; var i = 0, consec = 0; return new Promise(function (resT) { function finishT() { progressArr[ti] = 100; partials[ti] = statsOf(t.label, samples); report(); resT(partials[ti]); } function step() { once(t.url).then(function (rtt) { i++; if (rtt === null) { consec++; } else { consec = 0; if (i > WARMUP) samples.push(rtt); } progressArr[ti] = Math.min(100, i / perTotal * 100); partials[ti] = statsOf(t.label, samples); report(); if (consec >= 3) { finishT(); return; } // 無応答が続く拠点は早めに打ち切り if (i < perTotal) step(); else resT(partials[ti]); }); } step(); }); } Promise.all(targets.map(function (_, ti) { return runTarget(ti); })).then(function (arr) { var best = null; arr.forEach(function (x) { if (x.ok && (!best || x.median < best.median)) best = x; }); // 最速拠点 resolve({ targets: arr, best: best, ok: !!best }); }); }); } return { CFG: CFG, runDownload: runDownload, runUpload: runUpload, runPing: runPing, randomBlob: randomBlob }; })();