<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[A2Z Tools Dev Notes]]></title><description><![CDATA[aEngineering notes from A2Z Tools (https://a2z.tools), a library of 1,000+ free online tools. How we build browser-only developer tools with JavaScript and ASP.]]></description><link>https://a2ztools.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ae1ce76e69f7df0c28154cd/cedc3341-be80-460a-9676-863e6c322e6e.png</url><title>A2Z Tools Dev Notes</title><link>https://a2ztools.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 16:37:01 GMT</lastBuildDate><atom:link href="https://a2ztools.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a Keyboard Tester: event.code, Stuck Keys and the Keys a Browser Never Sees]]></title><description><![CDATA["Keyboard test online" gets searched by people with a sticky key, a second-hand laptop, or a new mechanical board to check. A web page is a good tool for…]]></description><link>https://a2ztools.hashnode.dev/building-a-keyboard-tester-event-code-stuck-keys-and-the-keys-a-browser-never-sees</link><guid isPermaLink="true">https://a2ztools.hashnode.dev/building-a-keyboard-tester-event-code-stuck-keys-and-the-keys-a-browser-never-sees</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Frontend Development]]></category><dc:creator><![CDATA[Syed Salman]]></dc:creator><pubDate>Fri, 30 Oct 2026 10:02:07 GMT</pubDate><content:encoded><![CDATA[<p>"Keyboard test online" gets searched by people with a sticky key, a second-hand laptop, or a new mechanical board to check. A web page is a good tool for this: no install, and every key you press lights up. But a naive tester gets a surprising number of things wrong. These are the fixes that went into the <a href="https://a2z.tools/keyboard-checker">keyboard tester</a> I rewrote. The old version was a jQuery script with a separate <code>if</code> block per key.</p>
<h2>Match keys by <code>event.code</code>, label them with <code>event.key</code></h2>
<p><code>event.key</code> is the character the key produces, and it changes with the layout and Shift. Press the same physical key on a French layout and you get <code>a</code> instead of <code>q</code>. Shift+1 gives <code>!</code>. A tester that matches on <code>key</code> can't light the right key cap for half the keyboard.</p>
<p><code>event.code</code> is the <em>physical position</em> (<code>KeyQ</code>, <code>Digit1</code>, <code>NumpadEnter</code>), and it stays the same whatever language the keyboard is typing in. So light keys by <code>code</code> and show <code>key</code> as the label:</p>
<pre><code class="language-js">const CODE_ALIASES = { OSLeft: 'MetaLeft', OSRight: 'MetaRight', NumpadComma: 'NumpadDecimal' };
const codeOf = e =&gt; CODE_ALIASES[e.code] || e.code;

document.addEventListener('keydown', e =&gt; {
  const code = codeOf(e);
  light(code, true);           // the physical key cap
  showLabel(e.key);            // what it typed on this layout
});
</code></pre>
<p>The aliases cover older browsers that reported the Windows/Command keys as <code>OSLeft</code>/<code>OSRight</code>.</p>
<h2>Stop the browser acting on keys, but keep real shortcuts</h2>
<p>Testing F5, Tab, Space or the arrow keys shouldn't reload or scroll the page, so <code>preventDefault()</code> on keydown. But blocking <em>everything</em> traps people: Ctrl+W, Ctrl+T, Ctrl+R and Cmd+L should still work. Let a combination through when Ctrl or Cmd is held with a non-modifier key:</p>
<pre><code class="language-js">const shortcut = (e.ctrlKey || e.metaKey) &amp;&amp; !/^(Control|Meta|OS|Alt|Shift)/.test(code);
if (!shortcut) e.preventDefault();
</code></pre>
<h2>Keys get stuck in your tester, not in the keyboard</h2>
<p>The classic bug is a key that stays lit after you've let go. It's almost never the keyboard. The page simply never received the <code>keyup</code>:</p>
<ul>
<li><strong>Alt+Tab, Cmd+Tab, or clicking another window.</strong> The keyup goes to whatever has focus by then.</li>
<li><strong>The Mac command key.</strong> While command is held, macOS swallows the keyups of other keys held with it.</li>
</ul>
<p>So release everything on <code>blur</code>, on <code>visibilitychange</code>, and when command is released:</p>
<pre><code class="language-js">window.addEventListener('blur', releaseAll);
document.addEventListener('visibilitychange', () =&gt; { if (document.hidden) releaseAll(); });
document.addEventListener('keyup', e =&gt; {
  const code = codeOf(e);
  held.delete(code);
  if (code === 'MetaLeft' || code === 'MetaRight') releaseAll();
});
</code></pre>
<p>Otherwise you're telling someone their keyboard is faulty when it isn't.</p>
<h2>Some keys only send keyup</h2>
<p>Print Screen on Windows, and some media keys, never produce a <code>keydown</code> the page can see. The OS takes it first. Only the <code>keyup</code> arrives. If you only listen for keydown, those keys look dead. So treat a keyup for a key you never saw go down as a press:</p>
<pre><code class="language-js">if (!held.has(code)) markTested(code);   // PrintScreen and friends
</code></pre>
<h2>And some keys send nothing at all</h2>
<p>The Mac <code>fn</code> (globe) key and Touch ID never reach a web page. Draw them, but mark them "not testable" and leave them out of the progress count. Otherwise nobody can ever reach 100%, and the honest answer ("the browser can't see this key") gets lost in "your fn key is broken".</p>
<h2>Count rollover while you're at it</h2>
<p>Holding several keys at once is how you find a keyboard's rollover limit (cheap boards often register only a few simultaneous keys before dropping or ghosting). Track the largest number of keys held at the same time:</p>
<pre><code class="language-js">held.add(code);
maxHeld = Math.max(maxHeld, held.size);
</code></pre>
<p>Press as many keys as you can together. If the count stops rising, that's the limit, at least for that combination of keys, because rollover on cheap keyboards depends on which keys share a matrix line.</p>
<h2>Draw the real keyboard</h2>
<p>Windows and Mac keyboards differ: Ctrl/Win/Alt/Menu versus control/option/command, a half-height function row on MacBooks, and inverted-T arrows. I describe each layout as a table on a quarter-key grid (half-key rows), so a 2.25-wide Enter, a stacked arrow pair and a two-row numpad Enter are all just grid placements. It's far easier to maintain than hand-written markup.</p>
<hr />
<p><em>I built the keyboard tester linked above. Drafted with AI assistance; the details are taken from the production code.</em></p>
]]></content:encoded></item><item><title><![CDATA[Writing a "Speaker Cleaner" in the Browser Without Lying to Anyone]]></title><description><![CDATA["Water in phone speaker" is one of those searches that spikes every summer. The usual fix people find is a page or app that plays a low tone to push the…]]></description><link>https://a2ztools.hashnode.dev/writing-a-speaker-cleaner-in-the-browser-without-lying-to-anyone</link><guid isPermaLink="true">https://a2ztools.hashnode.dev/writing-a-speaker-cleaner-in-the-browser-without-lying-to-anyone</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Web Audio]]></category><dc:creator><![CDATA[Syed Salman]]></dc:creator><pubDate>Fri, 30 Oct 2026 09:59:43 GMT</pubDate><content:encoded><![CDATA[<p>"Water in phone speaker" is one of those searches that spikes every summer. The usual fix people find is a page or app that plays a low tone to push the water out. I built one, a <a href="https://a2z.tools/speaker-cleaner">speaker cleaner</a>, and the interesting part wasn't generating a tone. It was everything around it: the physics, the clicks, the loudness, and being honest about what a web page can actually know.</p>
<h2>Water and dust are different problems</h2>
<p>A low frequency moves a speaker cone a long way on every cycle. That large excursion acts like a tiny pump, nudging droplets sitting in the grille up and out. Around <strong>165 Hz</strong> is a sensible compromise: low enough for real cone movement, but high enough that a phone's tiny driver can still produce it at a useful level. Go much lower and a small speaker barely reproduces it at all.</p>
<p>Dust is different. A slow, long stroke does little to a particle clinging to a mesh, while fast vibration does more. So the dust modes sit much higher (a 2 kHz pulse, and a sweep from 400 Hz to 4 kHz), because loose particles of different sizes let go at different frequencies.</p>
<p>So these are separate modes, not one tone with two labels:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>What it plays</th>
</tr>
</thead>
<tbody><tr>
<td>Standard pulse (water)</td>
<td>165 Hz, 300 ms on / 120 ms off</td>
</tr>
<tr>
<td>Deep pulse (water)</td>
<td>150 Hz, 600 ms on / 220 ms off</td>
</tr>
<tr>
<td>Water sweep</td>
<td>120 to 250 Hz and back</td>
</tr>
<tr>
<td>Dust shake</td>
<td>2 kHz, 220 ms on / 90 ms off, quieter</td>
</tr>
<tr>
<td>Dust sweep</td>
<td>400 Hz to 4 kHz, quieter</td>
</tr>
</tbody></table>
<p>None of these is a guarantee, and the page says so. They're presets chosen for plausible physics, not settings tuned per phone model.</p>
<h2>One oscillator, ever</h2>
<p>The obvious implementation creates an <code>OscillatorNode</code> per pulse. Don't. An oscillator can't be restarted once stopped, and creating one per pulse is how tools end up with overlapping tones that get louder the longer they run.</p>
<p>Instead, one oscillator runs for the whole cycle, and the pulses are shaped by scheduling ramps on a gain node, all on the audio clock:</p>
<pre><code class="language-js">const SILENCE = 0.0001;  // exponential ramps can't reach 0
const RAMP = 0.02;       // 20 ms click-free edge

let t = ctx.currentTime;
for (let i = 0; i &lt; count; i++) {
  gain.setValueAtTime(SILENCE, t);
  gain.exponentialRampToValueAtTime(peak, t + RAMP);
  gain.setValueAtTime(peak, t + pulse - RAMP);
  gain.exponentialRampToValueAtTime(SILENCE, t + pulse);
  t += pulse + gap;
}
</code></pre>
<p>Because everything is scheduled on <code>AudioContext</code> time, a throttled background tab can't knock the rhythm out of sync. The progress ring on screen runs off a 200 ms timer, but the sound doesn't depend on it. I also cap the number of scheduled events (400), so a bad duration value can't queue thousands of ramps.</p>
<h2>Never let the gain jump</h2>
<p>A gain that jumps from 0 to full makes a click. On a phone somebody already thinks is damaged, that click sounds like a fault, and like one the tool caused. So every pulse edge is ramped, and changing the power setting swells over 250 ms instead of stepping.</p>
<h2>Equal loudness: the dust mode would hurt</h2>
<p>Ears are far more sensitive around 2–4 kHz than at 165 Hz. If the dust shake played at the same electrical level as the water pulse, switching modes would make people jump, and tempt them to turn the volume down, which is exactly wrong for the low-frequency modes that need level.</p>
<p>The textbook equal-loudness gap is 30–40 dB, but that comes from the quiet end of the curves. At the levels a cleaning pulse plays at, the curves flatten a lot. A phone speaker's own low-frequency roll-off also makes 165 Hz come out much quieter than its electrical level suggests, while 2 kHz sits in the speaker's best range. Together, a taper of about −8 dB (a gain of 0.4) is enough to stop the dust modes being piercing without throwing away the vibration they exist for. The page also tells people to keep the phone away from their ears in dust mode.</p>
<p>At the end of the chain sits a brick-wall limiter (<code>DynamicsCompressorNode</code>: 20:1 ratio, hard knee, 1 ms attack, threshold −2 dBFS), so nothing ever reaches the device clipped:</p>
<pre><code>oscillator → envelope → power → limiter → output → speakers
                                             └→ analyser (visuals)
</code></pre>
<p>The analyser taps the final output, so the visuals show exactly what's handed to the speaker.</p>
<h2>What the browser can't know</h2>
<p>This is the part most "speaker cleaner" pages skip. A web page:</p>
<ul>
<li><strong>can't tell whether any water moved.</strong> It doesn't open the microphone, and even if it did, it couldn't measure droplets.</li>
<li><strong>can't tell which device played the sound.</strong> The built-in speaker, a Bluetooth headset and wired earbuds all look the same to Web Audio.</li>
<li><strong>can't play anything until you tap.</strong> Browsers hand back a suspended <code>AudioContext</code> without a user gesture, so <code>start()</code> runs from the button and calls <code>ctx.resume()</code> explicitly.</li>
</ul>
<p>So the tool reports what it <em>played</em> (mode, frequency, duration) and nothing more. The only verdict on the speaker is your own before-and-after comparison: play a song, run a cycle, play the song again.</p>
<p>Nothing is fetched or uploaded either; the tone is generated in the tab. If a "speaker cleaner" asks you to install something, or claims it detected water, it's telling you more than it can know.</p>
<hr />
<p><em>I built the speaker cleaner linked above. Drafted with AI assistance; the details are taken from the production code.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to run a user's regex without letting it freeze the browser tab]]></title><description><![CDATA[If your page lets people type a regular expression and run it, one bad pattern can lock the whole tab. JavaScript's RegExp engine backtracks, so a pattern…]]></description><link>https://a2ztools.hashnode.dev/how-to-run-a-user-s-regex-without-letting-it-freeze-the-browser-tab</link><guid isPermaLink="true">https://a2ztools.hashnode.dev/how-to-run-a-user-s-regex-without-letting-it-freeze-the-browser-tab</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Regex]]></category><category><![CDATA[web performance]]></category><dc:creator><![CDATA[Syed Salman]]></dc:creator><pubDate>Thu, 29 Oct 2026 20:03:35 GMT</pubDate><content:encoded><![CDATA[<p>If your page lets people type a regular expression and run it, one bad pattern can lock the whole tab. JavaScript's <code>RegExp</code> engine backtracks, so a pattern like this one</p>
<pre><code class="language-js">/^(a+)+$/
</code></pre>
<p>against <code>"aaaaaaaaaaaaaaaaaaaaaaaaaaaa!"</code> takes exponential time. The browser's main thread is busy the whole time, so clicks, scrolling and even the "Stop" button stop responding. There is no way to interrupt a running <code>RegExp.exec()</code> from the same thread.</p>
<p>I ran into this while building the regex tester on <a href="https://a2z.tools/regex-tester">A2Z Tools</a>. The fix turned out to be small: run the match in a Web Worker and kill the worker if it takes too long.</p>
<h2>1. Put the matcher in a worker built from a Blob</h2>
<p>You don't need a separate file. Build the worker source as a string and load it through a Blob URL:</p>
<pre><code class="language-js">const WORKER_SOURCE = `
self.onmessage = (e) =&gt; {
  const { pattern, flags, text } = e.data;
  try {
    const re = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g');
    const matches = [];
    let m, count = 0;
    while ((m = re.exec(text)) !== null &amp;&amp; count &lt; 5000) {
      matches.push({ index: m.index, match: m[0], groups: m.slice(1) });
      count++;
      if (m[0].length === 0) re.lastIndex++; // avoid an infinite loop on empty matches
    }
    self.postMessage({ ok: true, matches, truncated: count &gt;= 5000 });
  } catch (err) {
    self.postMessage({ ok: false, error: err.message });
  }
};`;
</code></pre>
<p>Two details matter here:</p>
<ul>
<li><strong>Empty matches.</strong> A pattern such as <code>/a*/g</code> can match an empty string. Without the <code>lastIndex++</code>, <code>exec()</code> returns the same empty match forever.</li>
<li><strong>A match cap.</strong> Even a fast pattern can return millions of matches. Capping at 5,000 keeps the <code>postMessage</code> payload sane.</li>
</ul>
<h2>2. Give it a time budget and terminate on timeout</h2>
<p><code>worker.terminate()</code> is the one thing that can stop a runaway regex, because it kills the worker's thread no matter what it is doing.</p>
<pre><code class="language-js">const TIME_BUDGET_MS = 1500;

function runRegex(pattern, flags, text) {
  return new Promise((resolve) =&gt; {
    const url = URL.createObjectURL(new Blob([WORKER_SOURCE], { type: 'application/javascript' }));
    const worker = new Worker(url);
    let settled = false;

    const finish = (result) =&gt; {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      worker.terminate();
      URL.revokeObjectURL(url);
      resolve(result);
    };

    const timer = setTimeout(
      () =&gt; finish({ ok: false, timedOut: true }),
      TIME_BUDGET_MS
    );

    worker.onmessage = (e) =&gt; finish(e.data);
    worker.onerror = (e) =&gt; finish({ ok: false, error: e.message });
    worker.postMessage({ pattern, flags, text });
  });
}
</code></pre>
<p>The <code>settled</code> flag is there because the timeout and the worker's reply can race. Whichever fires first wins, and the other is ignored.</p>
<p>A new worker per run sounds wasteful, but creating one takes a few milliseconds. It also means a terminated worker never leaves bad state behind for the next run.</p>
<h2>3. Validate input before you start the worker</h2>
<p>Cheap checks up front save a worker round trip and give clearer errors:</p>
<pre><code class="language-js">if (pattern.length &gt; 500) return error('Pattern is too long');
if (text.length &gt; 200_000) return error('Test text is too long');
if (!/^[gimsuy]*$/.test(flags)) return error('Flags may only contain g, i, m, s, u, y');
</code></pre>
<h2>4. Be honest about what a timeout means</h2>
<p>When the budget runs out, the UI says the pattern <em>may</em> be vulnerable to catastrophic backtracking <strong>on this input</strong>. A timeout is not proof that a pattern is unsafe, and finishing in time is not proof that it is safe. A different input could still blow up. If you need a real guarantee for server-side code, use a non-backtracking engine such as RE2. Or check for nested quantifiers like <code>(a+)+</code> and <code>(a|a)*</code>, which are the usual culprits.</p>
<h2>Try it</h2>
<p>You can paste <code>^(a+)+$</code> and a long run of <code>a</code>s followed by <code>!</code> into the <a href="https://a2z.tools/regex-tester">regex tester</a> and watch it time out after 1.5 s while the page stays responsive. The same pattern in a plain <code>&lt;script&gt;</code> would hang the tab.</p>
<hr />
<p><em>Disclosure: I'm the founder of A2Z Tools, where this tester lives. This post was drafted with AI assistance; the code is taken from the production tool. It first appeared on <a href="https://dev.to/md_salman_e2760b66fdcea1c/how-to-run-a-users-regex-without-letting-it-freeze-the-browser-tab-56i1">DEV</a>.</em></p>
]]></content:encoded></item></channel></rss>