Improvements

This commit is contained in:
jason5ng32 committed 2026-09-04 21:55:00 +08:00
1 parent 238ded1e1f
commit 05f837df27
3 files changed
+133 -28

No files matched your search

+26 -20
View File
@@ -22,7 +22,7 @@
<!-- Card grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<Card v-for="(leak, index) in leakTest" :key="leak.id"
<Card v-for="(leak, index) in leakTest" :key="leak.slot"
class="keyboard-shortcut-card jn-card min-w-0 overflow-hidden transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50">
<CardContent class="p-4 min-w-0">
<!-- Top: heartbeat icon + name + index -->
@@ -33,8 +33,7 @@
<span class="font-mono text-muted-foreground shrink-0">#{{ index + 1 }}</span>
</div>
<!-- Provider name (secondary information) — fixed per card, sourced
from each provider's `name` export in utils/dnsleaks. -->
<!-- Provider that actually answered (changes on fallback). -->
<p class="w-full min-w-0 mb-1 text-xs font-mono text-muted-foreground truncate" :title="leak.providerName">
{{ leak.providerName }}
</p>
@@ -108,13 +107,15 @@ import FitText from '@/components/widgets/FitText.vue';
import InfoBanner from '@/components/widgets/InfoBanner.vue';
import { INLINE_TIERS } from '@/composables/use-fit-text.js';
import {
ipApi, surfshark, ipleak, fastly, runWithRetry,
ipApi, surfshark, ipleak, fastly, browserleaks,
buildFallbackChain, runWithFallback,
} from '@/utils/dnsleaks';
// One source of truth for which providers to run and in what order. Adding
// a new provider = create the file under utils/dnsleaks and append it here.
// (browserleaks stays available in utils/dnsleaks but is benched for now.)
const PROVIDERS = [ipApi, surfshark, ipleak, fastly];
// All providers in priority order: the first SLOT_COUNT hold a card each,
// the rest are standbys. New provider = file under utils/dnsleaks + append.
const PROVIDERS = [ipApi, surfshark, ipleak, fastly, browserleaks];
const SLOT_COUNT = 4;
const ACTIVE = PROVIDERS.slice(0, SLOT_COUNT);
const { t } = useI18n();
@@ -151,8 +152,11 @@ const createDefaultCard = () => ({
org: t('dnsleaktest.StatusWait'),
});
const leakTest = reactive(PROVIDERS.map((p) => ({
// `slot` is the card's stable key; `id` / `providerName` follow the
// provider that actually answered.
const leakTest = reactive(ACTIVE.map((p) => ({
...createDefaultCard(),
slot: p.id,
id: p.id,
providerName: p.name,
})));
@@ -178,12 +182,14 @@ const markLeakCardError = (index) => {
leakTest[index].org = t('dnsleaktest.StatusError');
};
// Run one provider against its card slot, with retry (3 attempts, fresh
// prefix per attempt — see `runWithRetry` in utils/dnsleaks). On full
// failure (all attempts threw) the card is flipped to the error state.
const runProvider = async (index, provider) => {
// Run one slot through its fallback chain (see utils/dnsleaks); the card
// flips to error only when the whole chain fails.
const runProvider = async (index) => {
const chain = buildFallbackChain(index, PROVIDERS, SLOT_COUNT);
try {
const { ip } = await runWithRetry(provider);
const { ip, provider } = await runWithFallback(chain);
leakTest[index].id = provider.id;
leakTest[index].providerName = provider.name;
leakTest[index].ip = ip;
await applyMaxMindGeo(index, ip);
} catch (error) {
@@ -198,7 +204,9 @@ const checkAllDNSLeakTest = async (isRefresh) => {
isStarted.value = true;
if (isRefresh) {
trackEvent('Section', 'RefreshClick', 'DNSLeakTest');
leakTest.forEach((server) => {
leakTest.forEach((server, index) => {
server.id = ACTIVE[index].id;
server.providerName = ACTIVE[index].name;
server.ip = t('dnsleaktest.StatusWait');
server.country = t('dnsleaktest.StatusWait');
server.country_code = '';
@@ -206,15 +214,13 @@ const checkAllDNSLeakTest = async (isRefresh) => {
});
}
const delayedRun = (provider, index, delay) => new Promise((resolve) => {
const delayedRun = (index, delay) => new Promise((resolve) => {
setTimeout(() => {
runProvider(index, provider).then(resolve, resolve);
runProvider(index).then(resolve, resolve);
}, delay);
});
const promises = PROVIDERS.map((provider, index) =>
delayedRun(provider, index, index * 200),
);
const promises = ACTIVE.map((_, index) => delayedRun(index, index * 200));
const allSettledPromise = Promise.allSettled(promises);
const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 6000));
+29 -2
View File
@@ -1,4 +1,4 @@
// Barrel + retry runner for the homepage DNS leak test providers.
// Barrel + retry / fallback runners for the homepage DNS leak test providers.
//
// Each provider in this directory is a self-describing object:
// { id, name, run() }
@@ -21,7 +21,7 @@ export { fastly } from './fastly.js';
// whose `ip` fails `isValidIP` counts as a failed attempt too — garbage
// must never reach the MaxMind lookup downstream. Returns the first valid
// result; throws the last error if every attempt failed.
export async function runWithRetry(provider, attempts = 3) {
export async function runWithRetry(provider, attempts = 2) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
@@ -36,3 +36,30 @@ export async function runWithRetry(provider, attempts = 3) {
}
throw lastError;
}
// Try order for card slot `index`: own provider → standbys (index ≥
// slotCount) → the other slots' providers from 0. Each call derives a fresh
// hostname, so a neighbour's provider still yields an independent lookup.
export const buildFallbackChain = (index, providers, slotCount) => {
const primary = providers[index];
if (!primary) return [];
const standbys = providers.slice(slotCount);
const neighbours = providers.slice(0, slotCount).filter((p) => p !== primary);
return [primary, ...standbys, ...neighbours];
};
// Walk the chain once, each provider through `runWithRetry`; resolves
// `{ ip, provider }` so the caller can name the upstream that answered.
// Throws the last error.
export const runWithFallback = async (chain) => {
let lastError = new Error('dnsleak: empty provider chain');
for (const provider of chain) {
try {
const { ip } = await runWithRetry(provider);
return { ip, provider };
} catch (err) {
lastError = err;
}
}
throw lastError;
};
+78 -6
View File
@@ -1,12 +1,14 @@
// Exercises `runWithRetry` from frontend/utils/dnsleaks — the single entry
// point every DNS leak provider result flows through. Providers are stubbed;
// the behaviors under test are the retry loop and the isValidIP gate that
// keeps garbage upstream responses away from the MaxMind lookup.
// Exercises the runners in frontend/utils/dnsleaks with stubbed providers:
// the retry loop and its isValidIP gate (`runWithRetry`), the slot →
// standby → neighbour ordering (`buildFallbackChain`) and the chain walk
// that reports which provider answered (`runWithFallback`).
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { runWithRetry } from '../frontend/utils/dnsleaks/index.js';
import {
runWithRetry, buildFallbackChain, runWithFallback,
} from '../frontend/utils/dnsleaks/index.js';
const providerReturning = (results) => {
let call = 0;
@@ -63,7 +65,7 @@ describe('runWithRetry', () => {
() => runWithRetry(provider),
/stub: invalid IP in response/,
);
assert.equal(provider.calls(), 3);
assert.equal(provider.calls(), 2);
});
it('rejects a result missing the ip field entirely', async () => {
@@ -80,3 +82,73 @@ describe('runWithRetry', () => {
assert.equal(provider.calls(), 5);
});
});
const provider = (id, results) => ({ ...providerReturning(results), id, name: `${id}.example` });
describe('buildFallbackChain', () => {
const a = provider('a', []);
const b = provider('b', []);
const c = provider('c', []);
const d = provider('d', []);
const e = provider('e', []);
const registry = [a, b, c, d, e];
it('orders own provider, then the standbys beyond the slots, then the other slots from 0', () => {
assert.deepEqual(buildFallbackChain(1, registry, 3), [b, d, e, a, c]);
});
it('keeps registry order among several standbys', () => {
assert.deepEqual(buildFallbackChain(0, registry, 2), [a, c, d, e, b]);
});
it('is just the neighbours when there are no standbys', () => {
assert.deepEqual(buildFallbackChain(2, [a, b, c], 3), [c, a, b]);
});
it('is only the provider itself when it is the whole registry', () => {
assert.deepEqual(buildFallbackChain(0, [a], 1), [a]);
});
it('returns an empty chain for an index outside the registry', () => {
assert.deepEqual(buildFallbackChain(5, registry, 4), []);
});
});
describe('runWithFallback', () => {
it('resolves with the primary and never touches the rest of the chain', async () => {
const primary = provider('primary', [{ ip: '8.8.8.8' }]);
const spare = provider('spare', [{ ip: '1.1.1.1' }]);
const result = await runWithFallback([primary, spare]);
assert.deepEqual(result, { ip: '8.8.8.8', provider: primary });
assert.equal(spare.calls(), 0);
});
it('falls through to the next provider once the primary exhausts its attempts', async () => {
const dead = provider('dead', [new Error('down')]);
const spare = provider('spare', [{ ip: '1.1.1.1' }]);
const result = await runWithFallback([dead, spare]);
assert.equal(result.ip, '1.1.1.1');
assert.equal(result.provider, spare);
assert.equal(dead.calls(), 2);
assert.equal(spare.calls(), 1);
});
it('gives every provider in the chain the default retry budget', async () => {
const dead = provider('dead', [new Error('down')]);
const flaky = provider('flaky', [{ ip: 'garbage' }, { ip: '9.9.9.9' }]);
const result = await runWithFallback([dead, flaky]);
assert.equal(result.provider, flaky);
assert.equal(dead.calls(), 2);
assert.equal(flaky.calls(), 2);
});
it('throws the last error when the whole chain fails', async () => {
const first = provider('first', [new Error('first down')]);
const last = provider('last', [new Error('last down')]);
await assert.rejects(() => runWithFallback([first, last]), /last down/);
});
it('rejects an empty chain', async () => {
await assert.rejects(() => runWithFallback([]), /empty provider chain/);
});
});