A deep dive into building a lightweight Manifest V3 extension that parses 39 international currency formats and updates dynamic React/SSR DOM nodes in real-time without breaking event listeners.
When your Steam account region doesn't match the currency you budget in, browsing sales, checking inventory items, or analyzing Community Market skin graphs turns into constant mental math.
While heavy browser toolkits exist, I wanted something lightweight, 100% transparent, and capable of running directly inside the Steam Desktop Client (via client modding frameworks like Millennium/Extendium) as well as standard Chromium browsers.
Over a couple of nights, I built Steam Currency Converter — a zero-dependency Manifest V3 extension written in ~350 lines of pure Vanilla JS.
Here is a look at the four main engineering challenges I ran into and how I solved them without external libraries.
1. Zero-Latency Region Detection (SSR + DOM Fallback)
Before converting anything, the extension needs to know the store's native currency. Making an extra network request on every page load adds unnecessary latency, so I built a 3-step detection pipeline:
- DOM Config & SSR Scripts (
0 ms): Steam embeds user configuration in a[data-config]attribute or inline SSR scripts (wallet_country,g_strCountryCode). Reading this directly from the DOM gives us the 2-letter ISO country code instantly. -
sessionStorageCache: Once detected, the country code is cached for the active session so navigating across the Community Market requires zero re-computation. - Same-Origin Fallback: If a page lacks inline config data, the script performs a lightweight background
fetchto/market/accountor/account/using the active session cookies.
function getCountryFromDOM() { const configEl = document.querySelector("[data-config]"); if (configEl?.dataset?.config) { const cfg = JSON.parse(configEl.dataset.config); if (cfg.COUNTRY) return cfg.COUNTRY; } const regex = /\\?"(?:wallet_country|country_code|COUNTRY|strCountryCode)\\?"\s*:\s*\\?"([A-Z]{2})\\?"/; for (const script of document.scripts) { if (!script.textContent) continue; const match = script.textContent.match(regex); if (match?.[1]) return match[1]; } return null; } 2. Traversing the DOM Without Breaking React (TreeWalker API)
Modern Steam is a hybrid of legacy server-rendered templates and modern React components (especially discount blocks and interactive wishlists).
If you use element.innerHTML = element.innerHTML.replace(...), you destroy the underlying DOM nodes, wiping out React's internal state and event listeners.
Instead, I used the native TreeWalker API with NodeFilter.SHOW_TEXT. This lets the browser engine skip all HTML tags and iterate strictly over raw text nodes, replacing only the price substring in place:
const walker = document.createTreeWalker( document.body, NodeFilter.SHOW_TEXT, ); let node; const nodes = []; while ((node = walker.nextNode())) { if (TARGET_SYMBOL.some((sym) => node.textContent.includes(sym))) { nodes.push(node); } } Handling Live Market Graphs (MutationObserver)
On the Community Market, scrubbing your mouse across the median price history graph dynamically updates a single tooltip text node dozens of times per second.
By pairing TreeWalker with a 30ms throttled MutationObserver watching childList, subtree, and characterData, the extension converts graph tooltips in real-time as you move your cursor — with zero frame drops:
let debounce = false; const observer = new MutationObserver(() => { if (debounce) return; debounce = true; setTimeout(() => { cachedPage(); debounce = false; }, 30); }); observer.observe(document.body, { childList: true, subtree: true, characterData: true, }); 3. The Boss Fight: Parsing 39 International Number Formats
Steam supports 39 currencies, and every region formats numbers differently:
-
1,000.50(US, UK, Philippines) -
1.000,50(Germany, Brazil) -
1 000,50(Russia, Ukraine, Kazakhstan) -
1,200(Japan, South Korea — where,is a thousands separator, not decimals!) - Multi-character and regex-hostile currency symbols like
R$,$U,S/., or dual symbols like₱/P.
JavaScript's parseFloat() only understands . as a decimal separator. Here is the normalization parser that extracts the price from surrounding text (like "Starting at: 478₸") and normalizes any regional format into a clean float:
function parseSteamPrice(rawPrice) { if (!TARGET_SYMBOL || !/\d/.test(rawPrice)) return; const matchedSymbol = TARGET_SYMBOL.find((sym) => rawPrice.includes(sym)); if (!matchedSymbol) return; // Escape special characters in symbols like "R$", "$U", "S/." const escapedSym = matchedSymbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const numPattern = "\\d(?:[\\d\\s.,]*\\d)?"; const priceRegex = new RegExp( `(${escapedSym}\\s*${numPattern}|${numPattern}\\s*${escapedSym})`, ); const fullMatch = rawPrice.match(priceRegex)?.[0]; if (!fullMatch) return; const rawNumber = fullMatch.replace(matchedSymbol, "").trim(); let normalizedNumber = rawNumber.replace(/\s+/g, ""); // Normalize commas and dots for parseFloat() if (normalizedNumber.includes(",") && normalizedNumber.includes(".")) { if (normalizedNumber.lastIndexOf(",") < normalizedNumber.lastIndexOf(".")) { normalizedNumber = normalizedNumber.replace(/,/g, ""); // 1,000.50 -> 1000.50 } else { normalizedNumber = normalizedNumber.replace(/\./g, "").replace(",", "."); // 1.000,50 -> 1000.50 } } else if (normalizedNumber.includes(",")) { const parts = normalizedNumber.split(","); // If exactly 3 digits follow the comma, it's a thousands separator (e.g. 1,200) if (parts[parts.length - 1].length === 3) { normalizedNumber = normalizedNumber.replace(/,/g, ""); } else { normalizedNumber = normalizedNumber.replace(",", "."); } } const amount = parseFloat(normalizedNumber); if (isNaN(amount)) return; return { symbol: matchedSymbol, amount, fullMatch }; } Once converted, the number is formatted back into the target currency's native standard using Intl (toLocaleString) with smart rounding (preserving decimals for amounts < 10 and rounding larger figures to whole numbers).
4. Cross-Subdomain State & Security in Manifest V3
Initially, I used localStorage to cache the user's selected currency pair and exchange rate. However, Steam splits its ecosystem across two different origins:
-
store.steampowered.com(Storefront) -
steamcommunity.com(Inventory & Market)
Because localStorage is isolated per subdomain, settings in the Store didn't carry over to the Market. Switching to chrome.storage.local solved this cleanly, sharing state across both domains while keeping the extension in an isolated context.
When building tools for Steam (where users hold valuable inventories), security and trust are everything. Instead of injecting scripts into the MAIN world or bypassing CSP, the manifest.json requests the absolute bare minimum:
-
"permissions": ["storage"]— to store the cached rate. -
"host_permissions": ["https://open.er-api.com/*"]— a single GET request to fetch live rates when the user clicks "Convert Prices".
🔗 Try It Out / Source Code
The entire project is open-source (MIT) and available on GitHub. You can load it as an unpacked extension in any Chromium browser or inside the Steam Desktop Client via Extendium / Millennium:
👉 GitHub Repository: https://github.com/zxcfrancyz/steam-currency-converter
If you have any feedback on the regex parser or DOM traversal optimizations, I'd love to hear your thoughts in the comments!


