diff --git a/api/AGENTS.md b/api/AGENTS.md index a4d34e105..0d016c712 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -60,7 +60,9 @@ currently `dns-resolvers.js`, the country-annotated resolver list behind Root-level `sentry-instrument.js` (loaded via `node --import` *before* express, so ESM loader hooks can auto-instrument route tracing) does the init; `backend-server.js` attaches `setupExpressErrorHandler` after the - routes. No `SENTRY_DSN_BACKEND` → `@sentry/node` never loads. Handlers + routes. No `SENTRY_DSN_BACKEND` → `@sentry/node` never loads; + `SENTRY_ENVIRONMENT=development` skips the init, so a local run reports + nothing (same rule the cron check-ins already follow). Handlers never import Sentry or capture manually: uncaught throws and 5xx traces are automatic; caught failures stay on the logger — a hook in `common/logger.js` mirrors warn+ to Sentry Logs and elevates error+ to diff --git a/api/data/dns-resolvers.js b/api/data/dns-resolvers.js index 051125624..a606b84e2 100644 --- a/api/data/dns-resolvers.js +++ b/api/data/dns-resolvers.js @@ -52,4 +52,5 @@ export const DNS_RESOLVERS = [ { id: 'dns4eu', name: 'DNS4EU', country: 'EU', udp: '86.54.11.1' }, { id: 'cznic', name: 'CZ.NIC ODVR', country: 'CZ', udp: '193.17.47.1' }, { id: 'dnssb', name: 'DNS.SB', country: 'EU', udp: '185.222.222.222', doh: 'https://doh.dns.sb/dns-query?' }, + { id: 'kt', name: 'KT', country: 'KR', udp: '168.126.63.1' }, ]; diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 0094ce1e9..369e59f2b 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -76,7 +76,9 @@ replaces the map, and Home clears it on unmount — shortcuts are home-route onl ### Error monitoring (Sentry) is env-gated and invisible to app code No `VITE_SENTRY_DSN_FRONTEND` → no Sentry code in the bundle at all -(build-time-gated dynamic import, like `firebase-init.js`). Rules: +(build-time-gated dynamic import, like `firebase-init.js`). The same gate in +`main.js` skips the load under `import.meta.env.DEV`, so a `pnpm dev` run +reports nothing even with a DSN in `.env`. Rules: - **Never import `@sentry/vue` in app code** — a static import drags the SDK into the main bundle. All config lives in `sentry-init.js`. diff --git a/frontend/components/PrivacyPolicy.vue b/frontend/components/PrivacyPolicy.vue index 1cdf0f2da..ebb6b6d39 100644 --- a/frontend/components/PrivacyPolicy.vue +++ b/frontend/components/PrivacyPolicy.vue @@ -47,7 +47,7 @@ import { useMainStore } from '@/store'; import { isAnalyticsEnabled } from '@/utils/analytics'; import { isDocsConfigured } from '@/composables/use-docs-assistant.js'; import { useDocumentMeta } from '@/composables/use-document-meta.js'; -import { fallbackChain } from '@/utils/locale-registry.js'; +import { datasetLoaders, loadLocaleDataset } from '@/utils/locale-datasets.js'; import Footer from '@/components/Footer.vue'; import StandalonePageHeader from '@/components/StandalonePageHeader.vue'; import { Spinner } from '@/components/ui/spinner'; @@ -77,38 +77,28 @@ const isDocsAssistantEnabled = computed(() => isDocsConfigured && store.configs? // data/tools.js), which keeps the section off deployments without it. const isPersonaCheckEnabled = computed(() => store.configs?.originalSite === true); -// Privacy copy is loaded on demand per locale (mirrors the security-checklist -// dataset pattern), then merged into i18n so t() / tm() can resolve it. -// Discovered by glob, keyed by locale code; a locale with no file of its own -// resolves to the first one on its fallback chain that has one. -const privacyPacks = import.meta.glob('../locales/privacy/*.json'); -const privacyLoaders = Object.fromEntries( - Object.entries(privacyPacks).map(([path, loader]) => [path.match(/([^/]+)\.json$/)[1], loader]), -); - -const loaded = new Set(); +// Privacy copy is loaded on demand per locale (same loader as the security +// checklist dataset), then merged into i18n so t() / tm() can resolve it. +// A pack is complete or absent (tests/locale-packs.test.js), so whichever +// pack the chain resolves to is merged under the ACTIVE locale — the page +// reveals with one language, never a mix, and never raw keys unless the whole +// chain is unreachable. +const privacyLoaders = datasetLoaders(import.meta.glob('../locales/privacy/*.json')); +const privacyCache = new Map(); +const merged = new Set(); const ready = ref(false); -// Merge one locale's privacy copy into i18n (memoized). Does NOT touch `ready` — -// the caller decides when to reveal, so a background fallback load can't race the -// active locale and paint the wrong language. -const loadPrivacy = async (loc) => { - if (loaded.has(loc)) return; - const load = fallbackChain(loc).map((code) => privacyLoaders[code]).find(Boolean); - const { default: msgs } = await load(); - mergeLocaleMessage(loc, msgs); - loaded.add(loc); -}; - -// Reveal only after the ACTIVE locale's copy is merged — otherwise a fallback -// load could resolve first and paint English (t() falling back) until the next -// re-render. The rest of the chain (covering any key the active locale might -// miss) loads in the background and doesn't gate the reveal. watch(locale, async (loc) => { ready.value = false; - await loadPrivacy(loc); - if (loc === locale.value) ready.value = true; // ignore a stale load if locale changed mid-flight - for (const code of fallbackChain(loc).slice(1)) loadPrivacy(code); + const pack = await loadLocaleDataset(privacyLoaders, loc, privacyCache); + if (loc !== locale.value) return; // stale load — the locale changed mid-flight + if (pack) { + if (!merged.has(loc)) mergeLocaleMessage(loc, pack.data); + merged.add(loc); + } else { + console.error('Privacy copy unavailable in every locale; rendering keys'); + } + ready.value = true; }, { immediate: true }); // Ordered section ids, gated on which collection actually happens here. The diff --git a/frontend/components/User.vue b/frontend/components/User.vue index d50823f42..fb107cd5f 100644 --- a/frontend/components/User.vue +++ b/frontend/components/User.vue @@ -192,18 +192,17 @@ const refreshUserInfo = async () => { } }; -// Fetch user info +// Fetch user info. `remoteUserInfoFetched` flips only on success, so a failed +// round stays refetchable from Nav / Achievements on their next open. const getUserInfo = async () => { if (remoteUserInfoFetched.value || !isSignedIn.value) return; try { - const response = await authenticatedFetch(`/api/getuserinfo`); - const data = response; - store.remoteUserInfo = data; + store.remoteUserInfo = await authenticatedFetch(`/api/getuserinfo`); + store.remoteUserInfoFetched = true; initUserAchievements(); } catch (error) { console.error('Error fetching user info:', error); } - store.remoteUserInfoFetched = true; }; // Initialize user achievements @@ -267,8 +266,12 @@ watch(() => triggerUserBenefits.value, (newVal) => { if (newVal) openUserBenefits(); }); +// One-shot trigger: cleared here so the next request from Nav / Achievements +// is a fresh false → true edge rather than a no-op write. watch(() => triggerRemoteUserInfo.value, (newVal) => { - if (newVal) getUserInfo(); + if (!newVal) return; + store.triggerRemoteUserInfo = false; + getUserInfo(); }); watch(() => triggerUpdateAchievements.value, (newVal) => { diff --git a/frontend/components/advanced-tools/SecurityChecklist.vue b/frontend/components/advanced-tools/SecurityChecklist.vue index f128a0165..57a55e42a 100644 --- a/frontend/components/advanced-tools/SecurityChecklist.vue +++ b/frontend/components/advanced-tools/SecurityChecklist.vue @@ -257,7 +257,7 @@ import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { trackEvent } from '@/utils/analytics'; import { emitAppEvent } from '@/utils/app-events.js'; -import { fallbackChain } from '@/utils/locale-registry.js'; +import { datasetLoaders, loadLocaleDataset } from '@/utils/locale-datasets.js'; import { CircleProgressBar } from 'circle-progress.vue'; import VueMarkdown from 'vue-markdown-render'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; @@ -317,13 +317,11 @@ const { t, locale } = useI18n(); // The checklist dataset is large (~30 KB gzipped per language) and only this tool // reads it, so it's loaded on demand for the active locale instead of being baked -// into the initial i18n bundle (see frontend/locales/i18n.js). -// Discovered by glob, keyed by locale code; a locale with no dataset of its -// own resolves to the first one on its fallback chain that has one. -const securityDataPacks = import.meta.glob('../../locales/security-checklist/*.json'); -const securityDataLoaders = Object.fromEntries( - Object.entries(securityDataPacks).map(([path, loader]) => [path.match(/([^/]+)\.json$/)[1], loader]), -); +// into the initial i18n bundle (see frontend/locales/i18n.js). The shared loader +// walks the fallback chain, so a failed chunk degrades to the next language +// instead of a spinner that never stops. +const securityDataLoaders = datasetLoaders(import.meta.glob('../../locales/security-checklist/*.json')); +const securityDataCache = new Map(); const securityChecklist = ref(null); @@ -331,10 +329,15 @@ const securityChecklist = ref(null); // surfaces the template's existing loading state during the swap. const loadSecurityChecklist = async () => { fullList.value = null; - const load = fallbackChain(locale.value).map((code) => securityDataLoaders[code]).find(Boolean); - const { default: data } = await load(); - securityChecklist.value = data; - fullList.value = initSecurityList(securityChecklist.value); + const loc = locale.value; + const pack = await loadLocaleDataset(securityDataLoaders, loc, securityDataCache); + if (loc !== locale.value) return; // stale load — the locale changed mid-flight + if (!pack) { + console.error('Security checklist dataset unavailable in every locale'); + return; + } + securityChecklist.value = pack.data; + fullList.value = initSecurityList(pack.data); }; const store = useMainStore(); diff --git a/frontend/composables/use-screenshot.js b/frontend/composables/use-screenshot.js index e4e9816fb..74bc61bb2 100644 --- a/frontend/composables/use-screenshot.js +++ b/frontend/composables/use-screenshot.js @@ -121,8 +121,9 @@ function freezeInfoMaskForCapture(root) { return () => restorers.forEach((fn) => fn()); } -// Extension- or translator-injected cross-origin stylesheets make -// html-to-image's webfont embedding throw a SecurityError while reading +// Cross-origin stylesheets on `document.styleSheets` — mostly the docs +// assistant widget's, plus anything an extension or translator injected — +// make html-to-image's webfont embedding throw a SecurityError while reading // `cssRules`. Retry once without font embedding — the live page has already // loaded its fonts, so the rendered output is unaffected in practice. export const toPngWithFontFallback = async (toPng, element, options) => { diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 42c9ac23f..283433d87 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1973,7 +1973,7 @@ }, { "version": "v7.5.0", - "date": "Beta", + "date": "2026-09-03", "content": [ { "type": "add", diff --git a/frontend/data/connectivity-import-lists.js b/frontend/data/connectivity-import-lists.js index 9f1065f72..d1ac5c235 100644 --- a/frontend/data/connectivity-import-lists.js +++ b/frontend/data/connectivity-import-lists.js @@ -233,6 +233,24 @@ export const IMPORT_LISTS = [ { id: 'clickup', name: 'ClickUp', url: 'https://clickup.com/favicon.ico' }, ], }, + { + id: 'education', + emoji: '🎓', + members: [ + { id: 'coursera', name: 'Coursera', url: 'https://www.coursera.org/favicon.ico' }, + { id: 'edx', name: 'edX', url: 'https://www.edx.org/favicon.ico' }, + { id: 'khan-academy', name: 'Khan Academy', url: 'https://www.khanacademy.org/favicon.ico' }, + { id: 'udemy', name: 'Udemy', url: 'https://www.udemy.com/staticx/udemy/images/v8/favicon-32x32.png' }, + { id: 'duolingo', name: 'Duolingo', url: 'https://www.duolingo.com/robots.txt' }, + { id: 'codecademy', name: 'Codecademy', url: 'https://www.codecademy.com/favicon.ico' }, + { id: 'brilliant', name: 'Brilliant', url: 'https://brilliant.org/favicon.ico' }, + { id: 'skillshare', name: 'Skillshare', url: 'https://www.skillshare.com/favicon.ico' }, + { id: 'futurelearn', name: 'FutureLearn', url: 'https://www.futurelearn.com/favicon.ico' }, + { id: 'masterclass', name: 'MasterClass', url: 'https://www.masterclass.com/favicon-32x32.png' }, + { id: 'leetcode', name: 'LeetCode', url: 'https://leetcode.com/favicon.ico', iconDomain: 'assets.leetcode.com' }, + { id: 'mit-ocw', name: 'MIT OpenCourseWare', url: 'https://ocw.mit.edu/favicon.ico', iconDomain: 'mit.edu' }, + ], + }, { id: 'streaming', emoji: '🎬', @@ -325,6 +343,22 @@ export const IMPORT_LISTS = [ { id: 'heroku', name: 'Heroku', url: 'https://www.heroku.com/favicon.ico' }, ], }, + { + id: 'finance', + emoji: '💳', + members: [ + { id: 'paypal', name: 'PayPal', url: 'https://www.paypal.com/favicon.ico' }, + { id: 'wise', name: 'Wise', url: 'https://wise.com/robots.txt' }, + { id: 'revolut', name: 'Revolut', url: 'https://www.revolut.com/robots.txt' }, + { id: 'stripe', name: 'Stripe', url: 'https://stripe.com/favicon.ico' }, + { id: 'visa', name: 'Visa', url: 'https://www.visa.com/robots.txt' }, + { id: 'mastercard', name: 'Mastercard', url: 'https://developer.mastercard.com/favicon.ico', iconDomain: 'www.mastercard.com', siteUrl: 'https://www.mastercard.com' }, + { id: 'western-union', name: 'Western Union', url: 'https://www.westernunion.com/robots.txt' }, + { id: 'payoneer', name: 'Payoneer', url: 'https://www.payoneer.com/robots.txt' }, + { id: 'klarna', name: 'Klarna', url: 'https://docs.klarna.com/favicon.ico', iconDomain: 'www.klarna.com', siteUrl: 'https://www.klarna.com' }, + { id: 'monzo', name: 'Monzo', url: 'https://monzo.com/robots.txt' }, + ], + }, { id: 'crypto', emoji: '💰', diff --git a/frontend/data/default-preferences.js b/frontend/data/default-preferences.js index cd20d14b9..e03e7ee1e 100644 --- a/frontend/data/default-preferences.js +++ b/frontend/data/default-preferences.js @@ -13,7 +13,7 @@ export const PREFS_STORAGE_KEY = 'userPreferences_v7'; export const DEFAULT_PREFERENCES = Object.freeze({ theme: 'auto', // auto | light | dark - connectivityMultipleTests: false, + connectivityMultipleTests: true, simpleMode: false, // Per-module startup auto-run switches. IP info has no switch — it always // runs on load. See use-refresh-orchestrator.js. diff --git a/frontend/locales/en.json b/frontend/locales/en.json index e20d1ccb0..0f3437bfd 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1288,11 +1288,13 @@ "ai": "AI Services", "social": "Social & Messaging", "productivity": "Work & Productivity", + "education": "Online Education", "streaming": "Streaming", "music": "Music & Audio", "gaming": "Gaming", "developer": "Developer", "cloud": "Cloud & CDN", + "finance": "Finance & Payments", "crypto": "Crypto Exchanges", "ecommerce": "Global E-commerce", "news": "News & Wiki" diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 52b15ce36..7a19d6062 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1288,11 +1288,13 @@ "ai": "Services IA", "social": "Réseaux sociaux", "productivity": "Travail et productivité", + "education": "Formation en ligne", "streaming": "Streaming", "music": "Musique et audio", "gaming": "Jeux vidéo", "developer": "Développeurs", "cloud": "Cloud et CDN", + "finance": "Finance et paiements", "crypto": "Plateformes crypto", "ecommerce": "E-commerce mondial", "news": "Actualités" diff --git a/frontend/locales/i18n.js b/frontend/locales/i18n.js index 2109559ee..12146ad32 100644 --- a/frontend/locales/i18n.js +++ b/frontend/locales/i18n.js @@ -82,11 +82,15 @@ const i18n = createI18n({ messages: {}, }); -// Load one locale's messages into the instance (memoized). +// Load one locale's messages into the instance (memoized). A chunk that +// resolves without a default export leaves the locale unregistered rather +// than throwing — keys resolve down the chain. const loaded = new Set(); async function loadOne(locale) { if (loaded.has(locale) || !localeLoaders[locale]) return; - const { default: msgs } = await localeLoaders[locale](); + const mod = await localeLoaders[locale](); + const msgs = mod?.default; + if (!msgs) return; i18n.global.setLocaleMessage(locale, msgs); loaded.add(locale); } @@ -94,8 +98,9 @@ async function loadOne(locale) { // Load the active locale's whole fallback chain — vue-i18n can only fall back // to messages that are actually in the instance. Awaited in main.js before // mount so the first render is already translated; the loads run in parallel. +// allSettled: one unreachable pack leaves the UI partly translated, never blank. export async function loadActiveLocaleMessages() { - await Promise.all(fallbackChain(activeLocale).map((code) => loadOne(code))); + await Promise.allSettled(fallbackChain(activeLocale).map((code) => loadOne(code))); updateMeta(); } diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index 6bfce607c..94b8740e9 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -1288,11 +1288,13 @@ "ai": "Serviços de IA", "social": "Redes sociais e mensagens", "productivity": "Trabalho e produtividade", + "education": "Educação online", "streaming": "Streaming", "music": "Música e áudio", "gaming": "Jogos", "developer": "Desenvolvimento", "cloud": "Nuvem e CDN", + "finance": "Finanças e pagamentos", "crypto": "Corretoras cripto", "ecommerce": "E-commerce global", "news": "Notícias e wiki" diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index f6dcc67ea..7b0d74fd2 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -1288,11 +1288,13 @@ "ai": "ИИ-сервисы", "social": "Соцсети", "productivity": "Работа и продуктивность", + "education": "Онлайн-образование", "streaming": "Стриминг", "music": "Музыка и аудио", "gaming": "Игровые платформы", "developer": "Для разработчиков", "cloud": "Облака и CDN", + "finance": "Финансы и платежи", "crypto": "Криптобиржи", "ecommerce": "Электронная коммерция", "news": "Новости" diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index b15bcafcc..0c5dadd4b 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -1288,11 +1288,13 @@ "ai": "AI 服務", "social": "社交通訊", "productivity": "工作與生產力", + "education": "線上教育", "streaming": "流媒體", "music": "音樂與音訊", "gaming": "遊戲平臺", "developer": "開發者", "cloud": "雲與 CDN", + "finance": "金融與支付", "crypto": "加密交易所", "ecommerce": "國際電商平臺", "news": "新聞與百科" diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 567926124..f7c7e4a76 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -1288,11 +1288,13 @@ "ai": "AI 服务", "social": "社交通讯", "productivity": "工作与生产力", + "education": "在线教育", "streaming": "流媒体", "music": "音乐与音频", "gaming": "游戏平台", "developer": "开发者", "cloud": "云与 CDN", + "finance": "金融与支付", "crypto": "加密交易所", "ecommerce": "国际电商平台", "news": "新闻与百科" diff --git a/frontend/main.js b/frontend/main.js index 6b84c8cf0..559b9e6e9 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -66,16 +66,18 @@ const store = useMainStore(pinia); app.use(i18n); app.use(router); -// Sentry — build-time env gate: without the DSN the chunk neither ships nor -// loads. The SDK chunk stays OFF the boot critical path: it loads after -// mount (see the mount chain's finally below) so it never competes with the -// locale pack the first render waits on. Until init, a tiny buffer catches -// uncaught errors / rejections — and any of them triggers an immediate -// load, so a boot that never reaches mount still reports. Perf data -// survives the late init (buffered observers, backdated pageload span). +// Sentry — build-time env gate: without the DSN, or in a dev server run, the +// chunk neither ships nor loads (local runs would otherwise report every +// experiment against the production project). The SDK chunk stays OFF the +// boot critical path: it loads after mount (see the mount chain's finally +// below) so it never competes with the locale pack the first render waits +// on. Until init, a tiny buffer catches uncaught errors / rejections — and +// any of them triggers an immediate load, so a boot that never reaches mount +// still reports. Perf data survives the late init (buffered observers, +// backdated pageload span). const earlyErrors = []; let loadSentry = () => {}; -if (import.meta.env.VITE_SENTRY_DSN_FRONTEND) { +if (import.meta.env.VITE_SENTRY_DSN_FRONTEND && !import.meta.env.DEV) { const onEarlyError = (event) => { earlyErrors.push(event); loadSentry(); diff --git a/frontend/sentry-init.js b/frontend/sentry-init.js index 5108b802a..6945773b5 100644 --- a/frontend/sentry-init.js +++ b/frontend/sentry-init.js @@ -12,6 +12,14 @@ import { isValidIP } from '@/utils/valid-ip.js'; const env = import.meta.env ?? {}; +// html-to-image's webfont embedding console.errors on every stylesheet it +// can't read, then continues without it — noise, not a failed capture. +const SCREENSHOT_CSS_NOISE = [ + 'Error while reading CSS rules from', + 'Error loading remote stylesheet', + 'Error inlining remote css file', +]; + // `earlyErrors` is main.js's pre-init buffer: ErrorEvent / // PromiseRejectionEvent entries from its temporary window listeners, plus // the raw Error from the mount chain's catch. Flushed right after init; @@ -89,11 +97,15 @@ const initSentry = (app, router, earlyErrors = []) => { 'auth/internal-error', 'auth/cancelled-popup-request', 'INTERNAL ASSERTION FAILED', - // firebase auth's indexedDB layer refuses writes while the page - // is hiding (its guard against sign-out on pagehide); the - // rejection escapes from the SDK's own init promise — benign, - // means "skipped a write", not user-visible. - 'Database is closing/hidden', + // firebase auth's indexedDB persistence layer refuses writes once + // the page is hiding (its guard against sign-out on pagehide), and + // the browser itself rejects transactions on a closing connection. + // Both escape from the SDK's own init promise — benign, they mean + // "skipped a write", not user-visible. Three wordings, one per + // source; Sentry matches these as substrings. + 'Database is closing', + 'Database is hidden', + 'The database connection is closing', // Stale-deploy chunk loads: a client from before the latest // deploy lazy-loads a hashed asset that no longer exists. // Self-heals on reload, not a defect. One entry per browser @@ -130,6 +142,10 @@ const initSentry = (app, router, earlyErrors = []) => { const msg = firstArg.trim(); // Filter out DNS-leak probe chain errors. if (msg.startsWith('Error fetching leak test data:')) return null; + // html-to-image logs these while walking a cross-origin + // stylesheet it can't read, then carries on — the + // screenshot still renders (see composables/use-screenshot.js). + if (SCREENSHOT_CSS_NOISE.some((prefix) => msg.startsWith(prefix))) return null; event.fingerprint = [msg.slice(0, 200)]; } } diff --git a/frontend/store.js b/frontend/store.js index bb966f65c..54e5dbc80 100644 --- a/frontend/store.js +++ b/frontend/store.js @@ -221,9 +221,10 @@ export const useMainStore = defineStore('main', { ); this.setPreferences(merged); }, - // fetch configs from server + // Fetch configs from server. A longer timeout than the default: the boot + // request competes with every other first-render fetch on slow links. fetchConfigs() { - fetchWithTimeout('/api/configs') + fetchWithTimeout('/api/configs', { timeoutMs: 10000 }) .then(response => { if (!response.ok) { throw new Error('Network response was not ok'); diff --git a/frontend/utils/locale-datasets.js b/frontend/utils/locale-datasets.js new file mode 100644 index 000000000..0a0f09929 --- /dev/null +++ b/frontend/utils/locale-datasets.js @@ -0,0 +1,36 @@ +// Loader for the optional per-locale datasets (privacy copy, the security +// checklist): JSON chunks discovered with import.meta.glob and fetched on +// demand for the active locale. One place owns the failure policy so every +// consumer behaves the same: a chunk that fails to load, or resolves without +// a default export, is skipped and the next locale on the fallback chain is +// tried; only when the whole chain fails does the caller get null. +// +// const loaders = datasetLoaders(import.meta.glob('../locales/privacy/*.json')); +// const pack = await loadLocaleDataset(loaders, locale.value, cache); +// // pack → { code, data } | null + +import { fallbackChain } from './locale-registry.js'; + +// glob result ({ '../locales/privacy/zh.json': loader }) → { zh: loader }. +export const datasetLoaders = (packs) => Object.fromEntries( + Object.entries(packs).map(([path, loader]) => [path.match(/([^/]+)\.json$/)[1], loader]), +); + +// `cache` (a Map, per consumer) memoizes loaded data by pack code, so a locale +// switch back to a seen language never refetches. +export const loadLocaleDataset = async (loaders, code, cache = new Map()) => { + for (const candidate of fallbackChain(code)) { + if (cache.has(candidate)) return { code: candidate, data: cache.get(candidate) }; + const load = loaders?.[candidate]; + if (typeof load !== 'function') continue; + try { + const data = (await load())?.default; + if (!data) continue; + cache.set(candidate, data); + return { code: candidate, data }; + } catch (err) { + console.warn(`Locale dataset "${candidate}" failed to load, trying the next fallback:`, err); + } + } + return null; +}; diff --git a/package.json b/package.json index 17f805b4f..df25239a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "myip", "private": true, - "version": "7.4.0", + "version": "7.5.0", "type": "module", "packageManager": "pnpm@11.22.0", "engines": { diff --git a/public/favicons/brilliant.png b/public/favicons/brilliant.png new file mode 100644 index 000000000..a2c8643f1 Binary files /dev/null and b/public/favicons/brilliant.png differ diff --git a/public/favicons/codecademy.png b/public/favicons/codecademy.png new file mode 100644 index 000000000..463ba3902 Binary files /dev/null and b/public/favicons/codecademy.png differ diff --git a/public/favicons/coursera.png b/public/favicons/coursera.png new file mode 100644 index 000000000..df0c205dd Binary files /dev/null and b/public/favicons/coursera.png differ diff --git a/public/favicons/duolingo.png b/public/favicons/duolingo.png new file mode 100644 index 000000000..b188f5fff Binary files /dev/null and b/public/favicons/duolingo.png differ diff --git a/public/favicons/edx.png b/public/favicons/edx.png new file mode 100644 index 000000000..8bff60b2a Binary files /dev/null and b/public/favicons/edx.png differ diff --git a/public/favicons/futurelearn.png b/public/favicons/futurelearn.png new file mode 100644 index 000000000..26831d97a Binary files /dev/null and b/public/favicons/futurelearn.png differ diff --git a/public/favicons/khan-academy.png b/public/favicons/khan-academy.png new file mode 100644 index 000000000..b6e732d8a Binary files /dev/null and b/public/favicons/khan-academy.png differ diff --git a/public/favicons/klarna.png b/public/favicons/klarna.png new file mode 100644 index 000000000..0b05e608f Binary files /dev/null and b/public/favicons/klarna.png differ diff --git a/public/favicons/leetcode.png b/public/favicons/leetcode.png new file mode 100644 index 000000000..fbefd00dc Binary files /dev/null and b/public/favicons/leetcode.png differ diff --git a/public/favicons/mastercard.png b/public/favicons/mastercard.png new file mode 100644 index 000000000..407980e5b Binary files /dev/null and b/public/favicons/mastercard.png differ diff --git a/public/favicons/masterclass.png b/public/favicons/masterclass.png new file mode 100644 index 000000000..588291a22 Binary files /dev/null and b/public/favicons/masterclass.png differ diff --git a/public/favicons/mit-ocw.png b/public/favicons/mit-ocw.png new file mode 100644 index 000000000..43efc7523 Binary files /dev/null and b/public/favicons/mit-ocw.png differ diff --git a/public/favicons/monzo.png b/public/favicons/monzo.png new file mode 100644 index 000000000..d0600c232 Binary files /dev/null and b/public/favicons/monzo.png differ diff --git a/public/favicons/payoneer.png b/public/favicons/payoneer.png new file mode 100644 index 000000000..be301b852 Binary files /dev/null and b/public/favicons/payoneer.png differ diff --git a/public/favicons/paypal.png b/public/favicons/paypal.png new file mode 100644 index 000000000..cd4e35e87 Binary files /dev/null and b/public/favicons/paypal.png differ diff --git a/public/favicons/revolut.png b/public/favicons/revolut.png new file mode 100644 index 000000000..03765c107 Binary files /dev/null and b/public/favicons/revolut.png differ diff --git a/public/favicons/skillshare.png b/public/favicons/skillshare.png new file mode 100644 index 000000000..2ba33630d Binary files /dev/null and b/public/favicons/skillshare.png differ diff --git a/public/favicons/stripe.png b/public/favicons/stripe.png new file mode 100644 index 000000000..d96078f15 Binary files /dev/null and b/public/favicons/stripe.png differ diff --git a/public/favicons/udemy.png b/public/favicons/udemy.png new file mode 100644 index 000000000..db05db5c5 Binary files /dev/null and b/public/favicons/udemy.png differ diff --git a/public/favicons/visa.png b/public/favicons/visa.png new file mode 100644 index 000000000..61230b891 Binary files /dev/null and b/public/favicons/visa.png differ diff --git a/public/favicons/western-union.png b/public/favicons/western-union.png new file mode 100644 index 000000000..ab6d4e6ec Binary files /dev/null and b/public/favicons/western-union.png differ diff --git a/public/favicons/wise.png b/public/favicons/wise.png new file mode 100644 index 000000000..29000594e Binary files /dev/null and b/public/favicons/wise.png differ diff --git a/sentry-instrument.js b/sentry-instrument.js index e432669d4..a47ad4ed6 100644 --- a/sentry-instrument.js +++ b/sentry-instrument.js @@ -5,14 +5,18 @@ // // Gated on SENTRY_DSN_BACKEND, same philosophy as the frontend: when the env // var is unset this module does nothing and @sentry/node is never even -// loaded. backend-server.js attaches the matching Express error handler. +// loaded. SENTRY_ENVIRONMENT=development skips init too, so a local run never +// reports. backend-server.js attaches the matching Express error handler. import dotenv from 'dotenv'; import { scrubBreadcrumb, scrubEventRequest, scrubSpan } from './common/sentry-scrub.js'; dotenv.config({ quiet: true }); -if (process.env.SENTRY_DSN_BACKEND) { +// SENTRY_ENVIRONMENT=development (a local `pnpm dev` machine) skips init: +// experiments in progress are not production signal, same rule as the cron +// check-ins in common/sentry-cron.js. +if (process.env.SENTRY_DSN_BACKEND && process.env.SENTRY_ENVIRONMENT !== 'development') { const Sentry = await import('@sentry/node'); Sentry.init({ dsn: process.env.SENTRY_DSN_BACKEND, diff --git a/tests/default-preferences.test.js b/tests/default-preferences.test.js index d21d63842..e85298934 100644 --- a/tests/default-preferences.test.js +++ b/tests/default-preferences.test.js @@ -14,7 +14,7 @@ describe('DEFAULT_PREFERENCES', () => { it('contains the full preference shape with expected defaults', () => { assert.deepEqual(DEFAULT_PREFERENCES, { theme: 'auto', - connectivityMultipleTests: false, + connectivityMultipleTests: true, simpleMode: false, autoRunConnectivity: true, autoRunWebRTC: true, diff --git a/tests/locale-datasets.test.js b/tests/locale-datasets.test.js new file mode 100644 index 000000000..694bc510f --- /dev/null +++ b/tests/locale-datasets.test.js @@ -0,0 +1,76 @@ +// Guards frontend/utils/locale-datasets.js — the shared failure policy for +// the optional per-locale datasets: a failing or empty chunk falls through to +// the next locale on the chain, the cache short-circuits refetches, and only +// a whole-chain failure yields null. + +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import { datasetLoaders, loadLocaleDataset } from '../frontend/utils/locale-datasets.js'; + +const ok = (data) => async () => ({ default: data }); +const failing = (message) => async () => { throw new Error(message); }; +const empty = () => async () => ({}); + +let warn; +beforeEach(() => { warn = mock.method(console, 'warn', () => {}); }); +afterEach(() => { warn.mock.restore(); }); + +describe('datasetLoaders', () => { + it('keys glob entries by locale code', () => { + const zh = () => {}; + const en = () => {}; + const map = datasetLoaders({ '../locales/privacy/zh.json': zh, '../locales/privacy/en.json': en }); + assert.deepEqual(map, { zh, en }); + }); +}); + +describe('loadLocaleDataset', () => { + it('returns the active locale when its chunk loads', async () => { + const r = await loadLocaleDataset({ fr: ok('FR'), en: ok('EN') }, 'fr'); + assert.deepEqual(r, { code: 'fr', data: 'FR' }); + }); + + it('falls through the chain when the active chunk throws', async () => { + const r = await loadLocaleDataset({ fr: failing('boom'), en: ok('EN') }, 'fr'); + assert.deepEqual(r, { code: 'en', data: 'EN' }); + assert.equal(warn.mock.callCount(), 1); + }); + + it('treats a chunk without a default export like a missing one', async () => { + const r = await loadLocaleDataset({ fr: empty(), en: ok('EN') }, 'fr'); + assert.deepEqual(r, { code: 'en', data: 'EN' }); + }); + + it('walks regional → base → en', async () => { + const r = await loadLocaleDataset({ 'zh-TW': failing('x'), zh: ok('ZH'), en: ok('EN') }, 'zh-TW'); + assert.deepEqual(r, { code: 'zh', data: 'ZH' }); + const noBase = await loadLocaleDataset({ en: ok('EN') }, 'zh-TW'); + assert.deepEqual(noBase, { code: 'en', data: 'EN' }); + }); + + it('returns null only when the whole chain fails', async () => { + assert.equal(await loadLocaleDataset({ fr: failing('a'), en: failing('b') }, 'fr'), null); + assert.equal(await loadLocaleDataset({}, 'fr'), null); + assert.equal(await loadLocaleDataset(undefined, 'fr'), null); + }); + + it('caches by pack code and never refetches a cached pack', async () => { + const fr = mock.fn(ok('FR')); + const cache = new Map(); + await loadLocaleDataset({ fr, en: ok('EN') }, 'fr', cache); + const again = await loadLocaleDataset({ fr, en: ok('EN') }, 'fr', cache); + assert.deepEqual(again, { code: 'fr', data: 'FR' }); + assert.equal(fr.mock.callCount(), 1); + assert.equal(cache.get('fr'), 'FR'); + }); + + it('a cached fallback serves a locale whose own chunk keeps failing', async () => { + const cache = new Map(); + const loaders = { fr: failing('down'), en: ok('EN') }; + await loadLocaleDataset(loaders, 'fr', cache); + const r = await loadLocaleDataset(loaders, 'fr', cache); + assert.deepEqual(r, { code: 'en', data: 'EN' }); + assert.equal(cache.has('fr'), false); + }); +});