/* ============================================================ 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)(互換用。実測は graceMin/Max + measureMs で制御) grace: 800, // 旧・固定grace(ms)(互換用) graceMin: 600, // 適応grace:本計測を始める最短ウォームアップ(ms) graceMax: 4000, // 適応grace:頭打ち未検出でもここで本計測へ(ms)=高速回線の保険 measureMs: 5000, // 本計測の窓(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)=無応答拠点はここで打ち切り(固まり防止) }; // 接続方式モード:auto(CF併用・既定)/ ipv4(v4専用ホストのみで強制IPv4測定) CFG.spread = false; var SERVERSETS = { auto: { down: CFG.downServers, up: CFG.upServers, ping: CFG.pingTargets, spread: false }, ipv4: { down: [ 'https://ipv4.musen-lan.com/speed/data/dat/down_v3.php', // さくらVPS(v4強制) 'https://suites.musen-lan.com/speed/data/dat/down_v3.php', // WebARENA(v4のみ) 'https://ocn-ip.sakura.ne.jp/speed/data/dat/down_v3.php' // さくらレンタル(v4のみ) ], up: [ 'https://ipv4.musen-lan.com/speed/data/dat/up.php', 'https://suites.musen-lan.com/speed/data/dat/up.php', 'https://ocn-ip.sakura.ne.jp/speed/data/dat/up.php' ], ping: [ { label: 'さくらVPS', url: 'https://ipv4.musen-lan.com/speed/data/dat/ping.php' }, { label: 'WebARENA', url: 'https://suites.musen-lan.com/speed/data/dat/ping.php' }, { label: 'さくらRS', url: 'https://ocn-ip.sakura.ne.jp/speed/data/dat/ping.php' } ], spread: true // v4は3拠点へstreamを分散(CF無しなので合算で稼ぐ) }, ipv6: { down: [ 'https://ipv6.musen-lan.com/speed/data/dat/down_v3.php' ], // さくらVPS(v6強制・単独) up: [ 'https://ipv6.musen-lan.com/speed/data/dat/up.php' ], ping: [ { label: 'さくらVPS(v6)', url: 'https://ipv6.musen-lan.com/speed/data/dat/ping.php' } ], spread: false // v6はさくらVPS単独(v4専用のWebARENA/さくらレンタルは使えないため) } }; function setMode(m) { var s = SERVERSETS[m] || SERVERSETS.auto; CFG.downServers = s.down; CFG.upServers = s.up; CFG.pingTargets = s.ping; CFG.spread = !!s.spread; CFG.mode = SERVERSETS[m] ? m : 'auto'; // ログ識別用(down_v3.php?…&m=auto/ipv4/ipv6) return CFG.mode; } // ===== 接続方式の事前確認&CF合流(IPv4/IPv6選択時) ===== var CF = { down: 'https://bnr-speed.n-wakae.workers.dev/down', up: 'https://bnr-speed.n-wakae.workers.dev/up', ping: { label: 'Cloudflare', url: 'https://bnr-speed.n-wakae.workers.dev/' }, ipurl: 'https://bnr-speed.n-wakae.workers.dev/ip' }; var V6PING = 'https://ipv6.musen-lan.com/speed/data/dat/ping.php'; // AAAAのみ=IPv6でしか繋がらない var V4PING = 'https://ipv4.musen-lan.com/speed/data/dat/ping.php'; // Aのみ=IPv4でしか繋がらない // 系の到達確認:A固定/AAAA固定ホストへ繋がるか(no-cors=接続できたかだけ判定。繋がればresolve) function canReach(url) { return new Promise(function (resolve) { var ctrl = new AbortController(); var t = setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 3000); fetch(bust(url), { cache: 'no-store', mode: 'no-cors', signal: ctrl.signal }) .then(function () { clearTimeout(t); resolve(true); }) .catch(function () { clearTimeout(t); resolve(false); }); }); } // CFが今この端末から見て v4/v6 どちらで繋がっているか(Workerが接続元IPを返す /ip) function probeCF() { return new Promise(function (resolve) { fetch(bust(CF.ipurl), { cache: 'no-store' }) .then(function (r) { return r.text(); }) .then(function (ip) { ip = (ip || '').trim(); if (ip.indexOf(':') >= 0) resolve({ fam: 'IPv6', ip: ip }); else if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) resolve({ fam: 'IPv4', ip: ip }); else resolve({ fam: '?', ip: ip }); }) .catch(function () { resolve({ fam: 'err', ip: '' }); }); }); } // モード適用(async)。戻り値 {mode, block, label, info}。block=true なら測定不可(その系が使えない)。 function applyMode(m) { return new Promise(function (resolve) { setMode(m); if (m === 'ipv4') { canReach(V4PING).then(function (ok) { if (!ok) resolve({ mode: m, block: true, label: 'IPv4', info: 'この回線/端末は IPv4 で接続できませんでした(IPv6専用の可能性)。「自動」か「IPv6」をお試しください。' }); else resolve({ mode: m, block: false, label: 'IPv4 固定', info: 'IPv4到達OK' }); }); return; } if (m === 'ipv6') { canReach(V6PING).then(function (ok) { if (!ok) { resolve({ mode: m, block: true, label: 'IPv6', info: 'この回線/端末は IPv6 で接続できませんでした(IPv6未対応の可能性)。「自動」か「IPv4」をお試しください。' }); return; } probeCF().then(function (p) { if (p.fam === 'IPv6') { CFG.downServers = [CF.down].concat(CFG.downServers); CFG.upServers = [CF.up].concat(CFG.upServers); CFG.pingTargets = [CF.ping].concat(CFG.pingTargets); CFG.spread = false; // CF集中。v6自社ホストはCFが落ちた時の保険として末尾に残す resolve({ mode: m, block: false, label: 'IPv6 固定(CF経由)', info: 'IPv6到達OK・CFへv6接続(' + p.ip + ')→CF集中' }); } else { resolve({ mode: m, block: false, label: 'IPv6 固定', info: 'IPv6到達OK・CFはv4/未確認→v6自社ホストのみで測定' }); } }); }); return; } resolve({ mode: 'auto', block: false, label: '自動', info: '' }); // 自動はチェック不要 }); } 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: 'text/plain' }); // 別オリジンPOSTのCORSプリフライト(OPTIONS)回避=単純リクエスト化 } // 適応grace制御:連続ストリーム中、スループットが頭打ち(or 最大grace)になるまで温め、 // その後 measureMs ぶんだけ本計測する。→ 高速回線でスロースタートを拾わず初回過小評価を防ぐ。 function driveWindow(o) { var GMIN = CFG.graceMin || 600, GMAX = CFG.graceMax || 4000, MEAS = CFG.measureMs || 5000; var hist = [], plateauHits = 0; var iv = setInterval(function () { var now = performance.now(), total = o.getTotal(); hist.push({ t: now, total: total }); if (hist.length > 8) hist.shift(); var ms = o.getMS(); if (ms === null) { var el = now - o.start; if (el >= GMIN && hist.length >= 7) { var a = hist[hist.length - 7], b = hist[hist.length - 4], c = hist[hist.length - 1]; var r1 = (b.total - a.total) / Math.max(1, b.t - a.t); // 前半300msのレート var r2 = (c.total - b.total) / Math.max(1, c.t - b.t); // 後半300msのレート if (r1 > 0 && r2 > 0 && r2 <= r1 * 1.05) plateauHits++; // 伸びが5%未満=頭打ち else plateauHits = 0; } if (plateauHits >= 2 || el >= GMAX) { o.beginMeas(); ms = now; } // 頭打ち2連続 or 上限で本計測開始 } if (o.onProgress) { var bps, meas = o.getMeasured(), winMs = (ms !== null) ? (now - ms) : 0; if (winMs > 300 && meas > 0) { bps = meas * 8 / (winMs / 1000); // 本計測:区間の走行平均 } else if (hist.length >= 5) { var h0 = hist[hist.length - 5], h1 = hist[hist.length - 1], dt = (h1.t - h0.t) / 1000; bps = dt > 0 ? (h1.total - h0.total) * 8 / dt : 0; // 準備中:直近~400msの実レート(伸びを滑らかに表示) } else { var s2 = (now - o.start) / 1000; bps = s2 > 0 ? (total * 8 / s2) : 0; } var prog; if (ms === null) { prog = Math.min(45, (now - o.start) / GMAX * 45); // 準備中:時間比で 0〜最大45%(等速に進む) } else { var base = Math.min(45, (ms - o.start) / GMAX * 45); // 本計測開始時点の到達%(段差なく接続) prog = base + Math.min(1, (now - ms) / MEAS) * (100 - base); } o.onProgress(prog, bps); } if (ms !== null && (now - ms) >= MEAS) { clearInterval(iv); o.finish(); } }, 100); } // ---------- 下り ---------- 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 sidx = (CFG.spread ? (idx + attempt) : attempt) % CFG.downServers.length; var url = bust(CFG.downServers[sidx]) + '&m=' + (CFG.mode || 'auto'); // &m= はegress分析用の識別子(auto/ipv4/ipv6)。bustが必ず?t=を付けるので&で連結可 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 tasks = []; for (var i = 0; i < CFG.streams; i++) tasks.push(streamWorker(i)); driveWindow({ start: start, getTotal: function () { return total; }, getMeasured: function () { return measured; }, getMS: function () { return measureStart; }, beginMeas: function () { measureStart = performance.now(); }, onProgress: onProgress, finish: function () { stopped = true; controllers.forEach(function (c) { try { c.abort(); } catch (e) {} }); Promise.allSettled(tasks).then(function () { var winSec = (performance.now() - measureStart) / 1000; resolve({ mbps: winSec > 0 ? (measured * 8) / winSec / 1e6 : 0, bytes: measured, ok: (everData && measured > 0) }); }); } }); }); } // ---------- 上り ---------- 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) {} } for (var i = 0; i < CFG.streams; i++) streamUp(CFG.spread ? i : 0); // auto:CF起点 / ipv4:3拠点へ分散 driveWindow({ start: start, getTotal: function () { return total; }, getMeasured: function () { return measured; }, getMS: function () { return measureStart; }, beginMeas: function () { measureStart = performance.now(); }, onProgress: onProgress, finish: function () { stopped = true; xhrs.forEach(function (x) { try { x.abort(); } catch (e) {} }); var winSec = (performance.now() - measureStart) / 1000; resolve({ mbps: winSec > 0 ? (measured * 8) / winSec / 1e6 : 0, bytes: measured, ok: (everData && measured > 0) }); } }); }); } // ---------- 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, setMode: setMode, applyMode: applyMode }; })();