<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Andrea Roversi</title>
    <description>The latest articles on DEV Community by Andrea Roversi (@androve2k).</description>
    <link>https://dev.to/androve2k</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4006072%2F8e6fb2b9-126b-47aa-a022-18df714116cc.png</url>
      <title>DEV Community: Andrea Roversi</title>
      <link>https://dev.to/androve2k</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/androve2k"/>
    <language>en</language>
    <item>
      <title>Generating a Crossword in the Browser: Why Grid Backtracking Fails and What Works Instead</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Fri, 07 Aug 2026 08:47:02 +0000</pubDate>
      <link>https://dev.to/androve2k/generating-a-crossword-in-the-browser-why-grid-backtracking-fails-and-what-works-instead-3e76</link>
      <guid>https://dev.to/androve2k/generating-a-crossword-in-the-browser-why-grid-backtracking-fails-and-what-works-instead-3e76</guid>
      <description>&lt;p&gt;I wanted to add a crossword to my site's games, generated differently every round instead of pulled from a pre-made template. Between the idea and the first playable round there was a backtracking fill algorithm that always failed, a dictionary that grew from 200 to 671 words without fixing anything, and the discovery that the vocabulary had never been the real problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to generate a crossword, and the right question to ask first
&lt;/h2&gt;

&lt;p&gt;There are essentially three routes for a game like this.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fixed template&lt;/strong&gt;: prepare N grids with words and clues already crossed by hand, and the game draws one at random. Guaranteed quality, but finite content that repeats after a handful of rounds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic generation&lt;/strong&gt;: keep only a dictionary of words with clues, and a fill algorithm (backtracking over a grid with black squares) crosses random words together every round. Infinite variety, but a genuinely hard constraint-satisfaction problem, with client-side generation times that can spike badly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semi-dynamic&lt;/strong&gt;: fixed grid shapes, but the words that go into them are drawn at random from a length-compatible pool every round. A reasonable compromise between variety and simplicity — and the route I started from.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The first engine: fixed templates and backtracking fill
&lt;/h2&gt;

&lt;p&gt;I built the full game — grid, clues, timer, keyboard, local high-score saving — with an initial dictionary of about 200 words with clues (3-8 letters) and three fixed-shape grid templates, filled every round by a backtracking algorithm: try a length-compatible word in each open slot, and if a crossing doesn't work out, back up and try another.&lt;/p&gt;

&lt;p&gt;An automated stress test in Node — 200 iterations for each of the three templates — gave a clear result: failure almost every single time, even after growing the dictionary to nearly 400 entries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem wasn't the dictionary, it was the rigidity of the grid
&lt;/h2&gt;

&lt;p&gt;The cause wasn't the amount of available words, but the structure of the templates themselves. One of the three had a vertical word running through every row of the grid, crossing six horizontal words at once — and at each of those points, the last letter of a horizontal word needs to exactly match the letter the vertical word requires at that position.&lt;/p&gt;

&lt;p&gt;Italian words almost always end in a vowel and often start with a consonant: a combination of simultaneous constraints structurally too rigid for any reasonable amount of extra words to fix. It would take thousands of entries, not hundreds, to cover every possible combination at that spot in the grid.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The lesson that changed the approach.&lt;/strong&gt; A bigger dictionary doesn't fix a structurally over-dense crossing constraint. Past a certain point the problem stops being "how many words do I have" and becomes "how many simultaneous letter-by-letter combinations am I asking to satisfy at one single spot in the grid".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Generating the grid at runtime wasn't enough on its own
&lt;/h2&gt;

&lt;p&gt;The next attempt was dropping the three fixed templates and generating the grid's shape procedurally every round, retrying with a different layout on failure — same already-validated backtracking algorithm, but with the ability to switch templates instead of always hitting the same wall.&lt;/p&gt;

&lt;p&gt;Even here, though, stress tests showed the same limit: some crossing combinations remain mathematically too constrained for a hand-written dictionary, no matter how many different templates get generated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a freeform interlocking engine
&lt;/h2&gt;

&lt;p&gt;At that point I changed approach entirely: instead of solving an already-drawn empty grid (the classic constraint satisfaction problem, or CSP, that most crossword generators are built on), the engine builds the grid as it plays.&lt;/p&gt;

&lt;p&gt;It starts from a word drawn at random from the dictionary, then adds others one at a time, anchoring each new word to a letter already present in a word already placed, and checking that no conflicts arise with neighboring cells. The final shape of the grid emerges from the words themselves: there's never a pre-existing template that has to work out, so there's no "impossible" state the fill can get stuck in — it only ever builds what actually fits together.&lt;/p&gt;

&lt;p&gt;Validated offline in Python, where computation time isn't a constraint: 100 out of 100 attempts succeeded, on both difficulty levels, with compact, well-interconnected grids. Then ported to JavaScript to run in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  A bug that looked structural, and was just an execution order issue
&lt;/h2&gt;

&lt;p&gt;During the JavaScript port, a stress test extracted separately to check the exact browser code kept failing for an apparently serious reason: the list of all available words came out empty, even though the dictionary loaded correctly.&lt;/p&gt;

&lt;p&gt;It wasn't a flaw in the algorithm, but a problem in my own extraction script for the test: the order in which the two script blocks were read outside the page's real context didn't match the actual execution order in the browser. Once the extraction was fixed, the final stress test gave 200 successes out of 200 on both difficulties, at roughly 26 milliseconds per generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two interface bugs born straight out of the intersections
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The first&lt;/strong&gt;: typing horizontally onto a cell shared between two words would unexpectedly flip direction to vertical, as if that cell had been deliberately clicked twice. The cause was that the focus-moving function set a cell as "active" before actually giving it focus — so when the focus handler ran, the code saw "same cell already active" even during normal sequential typing, not only during a deliberate double click.&lt;/p&gt;

&lt;p&gt;The fix was separating the two cases: focus, even automatic focus during typing, no longer touches direction; the direction toggle only fires on a genuine click on a cell that was already the active one before that click, tracked separately through direct pointer-press events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second&lt;/strong&gt;: on a letter already written by a crossing word, typing the same letter again blocked automatic advancement. The text field had a one-character limit, and if you type the same character already present without the content being selected first, the browser doesn't count it as a change — so no event, no advancement.&lt;/p&gt;

&lt;p&gt;The fix had two parts: selecting the cell's content on every focus, so typing always replaces it, plus a direct keypress handler as a safety net for some mobile keyboards that don't fire the standard event.&lt;/p&gt;

&lt;h2&gt;
  
  
  The game isn't just the algorithm: background, dictionary, PWA
&lt;/h2&gt;

&lt;p&gt;With the engine stable, what remained were the details that separate "it works" from "it belongs on the site". The background animation shared with the other games (animated lines on canvas) was missing, and got added using the exact same pattern used elsewhere.&lt;/p&gt;

&lt;p&gt;The dictionary grew from 399 to 671 words, concentrating the growth on the thinner length buckets rather than the ones already well covered, and every addition was checked against duplicates before merging into the existing pool. With the bigger dictionary, a new stress test gave 300 successes out of 300 on both difficulties, at roughly 35 milliseconds per generation.&lt;/p&gt;

&lt;p&gt;Last step, the PWA side: icons generated with the same squircle convention used for every other app on the site, keeping the original artwork whole — it wasn't square to begin with — instead of cropping it, placing it on a square canvas with a thin border in the site's own color. On iPhone, three targeted fixes: removed the gray tap highlight on cells and buttons, enabled an immediate tap response instead of the browser's default delay, and disabled autocorrect on the text cells — otherwise iOS tries to "correct" crossword letters while you're playing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping the game: redirects, sitemap, and an SEO audit that caught stale copy
&lt;/h2&gt;

&lt;p&gt;Integrating the game into the site's games page also required a small layout change: slightly shrinking the cards so three fit per row instead of two, leaving the mobile behavior untouched. Then the less visible but equally necessary work: the redirect from the extensionless URL to the &lt;code&gt;.html&lt;/code&gt; one, and the matching sitemap entry.&lt;/p&gt;

&lt;p&gt;The final SEO audit found something more interesting than a simple character-limit overrun in the meta description: in three spots on the page — the intro paragraph, the visible FAQ section, and the same FAQ's structured data — the copy still said "almost 200 words" and described the engine as "validated fixed templates with backtracking". True sentences when they were written, turned false the exact moment the algorithm changed — and left silently in place, because nobody had gone back to check them after that change. All three were corrected, so the copy now says exactly what the game does today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take away from this
&lt;/h2&gt;

&lt;p&gt;A bigger dictionary doesn't fix a structurally over-dense crossing constraint: past a certain threshold, the problem stops being "how many words do I have" and becomes "how many simultaneous combinations am I asking to satisfy at one single spot in the grid" — at that point the robust fix is no longer more data, it's a different algorithm.&lt;/p&gt;

&lt;p&gt;Building the grid word by word instead of solving one already drawn removes every "impossible" state at the root — it's not a quality compromise, it's simply a different and easier problem.&lt;/p&gt;

&lt;p&gt;And finally, the most annoying bugs in this work were never the algorithm decisions, but the small, silent details of real interaction — a focus state set a moment too early, an identical character the browser doesn't register as a change, descriptive copy nobody updates after the underlying logic changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why can a backtracking algorithm on a fixed grid fail to generate a crossword even with hundreds of words in the dictionary?&lt;/strong&gt;&lt;br&gt;
Because the constraint that matters isn't how many words exist in total, but how many simultaneous intersections are needed at one spot in the grid. A vertical word crossing six horizontal words at once needs a coverage of letter-by-letter combinations that only a dictionary of thousands of entries can reliably provide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is a "freeform interlocking" crossword generator?&lt;/strong&gt;&lt;br&gt;
Instead of filling an already-drawn empty template, the generator builds the grid: it starts from one word, then adds others one at a time, anchoring each to a letter already present in a previously placed word. The grid's final shape emerges from the words themselves, so the puzzle is solvable by construction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is it better to generate the grid at runtime instead of using a handful of pre-drawn fixed templates?&lt;/strong&gt;&lt;br&gt;
Because every fixed template carries a rigid crossing pattern that the dictionary either always satisfies or never does — with only a few templates, the puzzle ends up failing at the same spot every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is it worth redoing an SEO and content audit after a radical change to a page's algorithm?&lt;/strong&gt;&lt;br&gt;
Because copy written to describe "how a page works" becomes false the exact moment the implementation changes, and it survives silently both in the visible text and in the structured data — worth explicitly rechecking after every substantial change, not just at first publish.&lt;/p&gt;




&lt;p&gt;You can play the finished game for free at &lt;a href="https://roversia.it/giochi/cruciverba.html" rel="noopener noreferrer"&gt;roversia.it/giochi/cruciverba&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://roversia.it/blog-26-cruciverba-javascript-algoritmo-incastro-libero-en.html" rel="noopener noreferrer"&gt;roversia.it&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>algorithms</category>
      <category>webdev</category>
      <category>pwa</category>
    </item>
    <item>
      <title>I launched Roversia on Product Hunt — 39+ free browser tools, zero frameworks</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Thu, 06 Aug 2026 09:10:22 +0000</pubDate>
      <link>https://dev.to/androve2k/i-launched-roversia-on-product-hunt-39-free-browser-tools-zero-frameworks-1h0p</link>
      <guid>https://dev.to/androve2k/i-launched-roversia-on-product-hunt-39-free-browser-tools-zero-frameworks-1h0p</guid>
      <description>&lt;p&gt;Today I launched Roversia on Product Hunt — a free, ad-free platform of 39+ browser-based utility tools I've been building solo.&lt;/p&gt;

&lt;p&gt;The stack is deliberately minimal: vanilla JavaScript, no frontend framework, no bundler. Backend is Firebase (Auth, Firestore, RTDB, Storage) with Netlify Functions for the few things that need server-side logic — PDF processing, API proxying, that kind of thing. Zero recurring server costs was a hard constraint from day one.&lt;/p&gt;

&lt;p&gt;Some of the tools run entirely client-side, including AI-powered ones — background removal runs fully in-browser via ONNX/Transformers.js, no server round-trip.&lt;/p&gt;

&lt;p&gt;If you're curious about the implementation details, I've been writing about specific pieces on this blog — Firebase auth patterns, PWA architecture, that ONNX integration, and more.&lt;/p&gt;

&lt;p&gt;Would genuinely appreciate feedback on Product Hunt if you have a minute, especially on which tools are most/least useful: &lt;a href="https://www.producthunt.com/products/roversia?utm_source=devto&amp;amp;utm_medium=social" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/roversia?utm_source=devto&amp;amp;utm_medium=social&lt;/a&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>Bringing an External CRM's Chats into Firestore for AI Search: Vector Search, Webhooks, and a Stubborn Bundling Error</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Mon, 03 Aug 2026 12:00:22 +0000</pubDate>
      <link>https://dev.to/androve2k/bringing-an-external-crms-chats-into-firestore-for-ai-search-vector-search-webhooks-and-a-1898</link>
      <guid>https://dev.to/androve2k/bringing-an-external-crms-chats-into-firestore-for-ai-search-vector-search-webhooks-and-a-1898</guid>
      <description>&lt;p&gt;A client wanted their management panel's AI assistant to answer questions like "what did this contact write me" or "who replied about a certain topic", pulling from thousands of conversations held on an external CRM. Between the idea and the first correct answer there was a bundling error that came back three times, a cursor that never advanced, and a phone number that pointed to the wrong contact.&lt;/p&gt;

&lt;h2&gt;
  
  
  What already existed, and what was really missing
&lt;/h2&gt;

&lt;p&gt;The client's management panel already had a solid integration with the external CRM used to handle WhatsApp, SMS, and email conversations with contacts: reading sales pipelines, opening chat with bubbles and attachments, sending replies. All reusable infrastructure, already in production for a while. One specific piece was missing: no function queried the CRM's full contact directory — only opportunities inside a specific pipeline — and, more importantly, there was no way at all for the panel's AI assistant to read past conversations.&lt;/p&gt;

&lt;p&gt;First rule, before writing a single new line of code: reuse what already worked. The function that read multiple pipeline stages in parallel, the modal that opened chat with full history, the function that sent messages — all proven and already in use. The one genuinely missing piece was a function able to search the entire contact directory, with text search and pagination, which the existing functions (built for single pipelines) didn't do.&lt;/p&gt;

&lt;h2&gt;
  
  
  A search that has to run across all contacts, not just the 30 loaded on screen
&lt;/h2&gt;

&lt;p&gt;With tens of thousands of contacts in the directory, loading them all client-side to filter isn't an option. The correct approach is server-side search: on every keystroke (debounced by 400ms to avoid hammering the API), the backend function forwards the query to the CRM's own search engine, which runs it against the entire archive — name, phone, email — not just the results already shown on screen. What the user sees, 30 contacts at a time with a "load more" button, is only the current page, not the search scope.&lt;/p&gt;

&lt;p&gt;A real limitation worth knowing: a third-party CRM's search is almost always a "contains" match, not fuzzy — it doesn't tolerate typos or a swapped first/last name. Worth keeping in mind both in the UI and, later in this story, in a piece of AI logic that broke for exactly this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two small but annoying interface bugs
&lt;/h2&gt;

&lt;p&gt;Before tackling the hard part, two minor fixes noticeably improved the contact directory's usability. The first was a misalignment between column headers and table rows: caused by using two different layout systems, a flex container for the header and a real table for the rows, which could never match in width. The fix was putting the header inside a real &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt; in the same table, with a &lt;code&gt;&amp;lt;colgroup&amp;gt;&lt;/code&gt; fixing the same widths for both.&lt;/p&gt;

&lt;p&gt;The second was a cascading tag filter: a new function lists every tag that exists in the account (not just the ones seen among the contacts already loaded on screen), populates a dropdown, and selecting one reruns the same text search passing the tag name — zero new endpoints for the actual filtering, since the CRM's search engine already covered tags too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request that changes the scale of the project
&lt;/h2&gt;

&lt;p&gt;At that point came the request that changed the nature of the work: the client wanted every contact and every conversation to also persist in their own database, kept up to date over time, queryable by the panel's AI assistant — no longer just fetched on the fly from the CRM when needed. With nearly 48,000 contacts and a chat history growing daily, this stopped being one more feature and became an architecture decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Firestore and not Realtime Database
&lt;/h2&gt;

&lt;p&gt;The first decision, and the one worth getting right: Realtime Database retransmits the entire node on every write, to every connected client. With tens of thousands of contacts and conversations that keep growing, a node like &lt;code&gt;contacts/{id}/messages&lt;/code&gt; would have become a bandwidth problem from the very first wave of writes. Firestore, by contrast, supports indexed queries, subcollections that grow without rewriting the parent document, and — the decisive reason here — native vector search, exactly what's needed for semantic search across all conversations at once, without adding a separate vector database to pay for and manage.&lt;/p&gt;

&lt;p&gt;The data model is one document per contact (profile, tags, last sync) with a &lt;code&gt;messages&lt;/code&gt; subcollection for history — not an array inside the document itself. A contact with hundreds of messages would soon hit the one-megabyte-per-document limit, and more importantly every new message writes a single new document instead of rewriting the entire history each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The batched backfill, and the first obstacle: "Cannot find package firebase-admin"
&lt;/h2&gt;

&lt;p&gt;Writing and querying vector fields on Firestore reliably means using the official &lt;code&gt;firebase-admin&lt;/code&gt; SDK, not the raw REST API. That runs against a project convention — zero npm dependencies in the existing Netlify Functions — and the exception made itself felt on the very first deploy: &lt;code&gt;Cannot find package 'firebase-admin' imported from /var/task/...&lt;/code&gt;, despite the package being correctly declared in a dedicated &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cause is a function-bundling problem, not a code problem: the bundler simply wasn't packaging the dependency into the final zip. First attempt, explicitly setting the modern bundler via &lt;code&gt;node_bundler = "esbuild"&lt;/code&gt; in the config — same error. Second attempt, explicitly marking the package as "external" so it wouldn't be bundled (&lt;code&gt;external_node_modules&lt;/code&gt;), the documented standard fix for exactly this case — again the identical error, a sign the problem was no longer bundling but the install step itself during the build.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The definitive fix, when the "clean" routes aren't enough.&lt;/strong&gt; No more half-measures: download the dependencies ahead of time and commit them already installed into the repository (so-called "vendoring" of &lt;code&gt;node_modules&lt;/code&gt;), so the platform finds them already there instead of having to install them during the build.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The interesting practical detail: the client works entirely from a browser, with no local terminal. The solution was a browser-accessible cloud dev environment (a "codespace") with a real built-in terminal: &lt;code&gt;npm install&lt;/code&gt; inside the functions folder, then commit and push the generated files straight from the interface — zero commands to install on the person's own computer. With &lt;code&gt;node_modules&lt;/code&gt; physically present in the repository, the next deploy went through on the first try.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cursor that never advanced (and the hidden cost of redoing the same work)
&lt;/h2&gt;

&lt;p&gt;With bundling solved, actually importing nearly 48,000 contacts can't be a single call: it has to run in batches, repeated by a function scheduled every few minutes, with a progress cursor saved on Firestore to resume exactly where it left off. The first real run revealed a subtler problem: the cursor stayed &lt;code&gt;null&lt;/code&gt; forever. The configured batch (25 contacts) simply couldn't finish within the function's time budget, so every execution restarted from the same initial contacts — not only failing to advance, but also re-paying for embedding every message that had already been embedded before, a silent and entirely avoidable cost.&lt;/p&gt;

&lt;p&gt;The fix had two parts: shrink the batch size to something that genuinely completes within the available time budget, and — more importantly — check which messages already had an embedding saved from a previous run, skipping them instead of regenerating them. With that fix, the cursor finally started advancing, and a rough estimate based on the observed pace put the full historical import at about a day and a half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-time messages: a "customer replied" trigger that isn't enough on its own
&lt;/h2&gt;

&lt;p&gt;For new messages, the CRM's automation offered a "customer replied"-style trigger — but with a non-obvious limitation: the variables available in the webhook body exposed the contact's identity, not the message text or its real timestamp. And, more importantly, no equivalent trigger existed for "the operator replied": automation engines of this kind react to customer actions, not to internal team actions.&lt;/p&gt;

&lt;p&gt;The sturdier fix wasn't chasing a trigger that probably doesn't exist, but changing how the available one gets used: the webhook no longer carries the message text, only the contact's identity, used as a signal meaning "this contact was just active, resync it now." The function it calls re-reads the entire fresh conversation from the same API the backfill already uses — which includes both inbound and outbound messages in the same fetch. One available trigger, full coverage on both directions. A second, lighter webhook, attached to contact-created/updated triggers, covers tag or profile changes that happen without a new message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dirty data in the pipeline: system events mistaken for messages
&lt;/h2&gt;

&lt;p&gt;An unplanned side effect: imported messages started including system events like "opportunity deleted", generated by the CRM whenever an opportunity changes stage — not text written by either side of the conversation. The fix, minimal but needed in three separate places (the real-time webhook, the shared sync function, and the backfill job already live in production), was to explicitly filter these events by type before saving them or generating an embedding for them.&lt;/p&gt;

&lt;p&gt;A practical reminder about this kind of change: an import job that's already live and running should be fixed with surgical patches, not rewritten for code cleanliness midway through — the risk of introducing a new bug while it's processing tens of thousands of records far outweighs the cosmetic benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Searching by phone, searching by name — and a silent ambiguity
&lt;/h2&gt;

&lt;p&gt;The first real test of AI search immediately exposed the limit of semantic search: searching for a phone number with the vector engine doesn't work, because the number doesn't appear in the meaning of the message text. What was needed was a direct identity lookup, not a content search — two different tools for two different questions.&lt;/p&gt;

&lt;p&gt;The first attempt at a phone lookup produced a sneakier bug: a ten-digit local number that happened, by pure coincidence, to start with the same digits as the Italian country code was mistaken for an already-complete international number — and the search stopped at the first match found, returning the wrong contact with no warning at all. The correct fix wasn't "guess better" but checking every plausible variant in parallel (with and without the country code) and, if different contacts turn up, explicitly flagging the ambiguity instead of picking one at random. The same principle was then extended to a direct lookup by exact name and by tag, both with an automatic, silent fallback to semantic search whenever the direct match finds nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong time zone in the AI's answer
&lt;/h2&gt;

&lt;p&gt;An easy detail to underestimate: timestamps were correctly stored in UTC, as they should be, but were passed to the language model as-is, leaving it to mentally convert them to local time in its final answer. The result was silently off by two hours, with no error to catch — a message from 1:22 PM local time was presented as 11:22 AM.&lt;/p&gt;

&lt;p&gt;The reliable fix wasn't a more insistent prompt instruction, but removing that calculation from the model entirely: converting the timestamp server-side to the correct time zone (automatically handling daylight saving as well) before injecting it into the context, and explicitly stating that the time received is already local, so the model doesn't attempt the conversion again on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long an AI answer should be
&lt;/h2&gt;

&lt;p&gt;One last problem, more mundane but frequent: some answers with long lists of messages were getting cut off halfway, with the last item — often the most recent and the most relevant one — missing. The cause was simply a response token budget too low for the language model. The fix has three layers, not just one: raise the maximum response token budget, order lists from most recent message to oldest (so any future truncation drops the least relevant data, not the freshest), and explicitly instruct the model to stay compact in its formatting, so it doesn't waste response headroom on unnecessary decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take away from this
&lt;/h2&gt;

&lt;p&gt;The broader lesson is that reusing existing infrastructure saves real time, but changing scale (from a few on-the-fly reads to tens of thousands of persistent, queryable records) is always an architecture decision, not just a code one — worth stopping to decide explicitly before writing the first line. Making an exception to an established convention like "zero npm dependencies" needs a plan B ready in advance: if standard bundling doesn't work on the first try, manually vendoring dependencies is a slow but reliable path, and it's better to know that before deploy day, not during it. Identity search and semantic search are two different tools, not one tool with two modes: using the wrong one produces a result that looks plausible but is silently incorrect, the hardest kind of bug to spot. And finally, the most annoying bugs in this work were never the "big" decisions — Firestore or RTDB, vector or text search — but the small, silent details: a time budget too tight, a time zone left unconverted, a token limit set too low. None of these throws a visible error: they just produce a wrong answer that looks right.&lt;/p&gt;

&lt;p&gt;If you're interested in the broader reasoning behind database choices in this same management panel, I also wrote about migrating RTDB rules after an authentication issue. And if you want to see how the AI assistant that learned to read these chats is structured in the first place, the starting article is the one on the Gemini API chatbot inside the management panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why use Firestore instead of Realtime Database to store a CRM's conversations?&lt;/strong&gt;&lt;br&gt;
Because Realtime Database retransmits the entire node on every write, to every connected client: with tens of thousands of contacts and growing conversations that becomes a bandwidth problem. Firestore instead supports indexed queries, subcollections that grow without rewriting the parent document, and the native vector search needed for semantic search.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is vector search and what is it useful for in a case like this?&lt;/strong&gt;&lt;br&gt;
It's a search by meaning rather than by exact words: each message is turned into a numeric vector (embedding) by an AI model, and a query of the same kind finds the semantically closest messages. It's useful for questions like "who asked for a refund this month", where the exact wording of the question never appears in the original messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why can a Netlify Function with a dependency like firebase-admin fail in production even if it works locally?&lt;/strong&gt;&lt;br&gt;
Because the function bundler sometimes fails to correctly package heavy dependencies with native bindings or dynamic &lt;code&gt;require()&lt;/code&gt; calls, even when explicitly setting the modern bundler or excluding the package from the bundle. In the most stubborn cases the only reliable fix is vendoring &lt;code&gt;node_modules&lt;/code&gt; — committing it already installed into the repository instead of letting it install during the build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why can a phone number without its country code lead to the wrong contact?&lt;/strong&gt;&lt;br&gt;
Because a search that stops at the first match found, without checking every plausible variant of the number (with and without the country code), can return a different contact than the one being searched for by pure numeric coincidence. The correct fix is checking every variant in parallel and explicitly flagging an ambiguity instead of picking one at random.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it possible to capture both inbound and outbound CRM messages in real time?&lt;/strong&gt;&lt;br&gt;
It depends on the CRM: many automation engines offer a "customer replied" trigger but no native trigger for "the operator replied". In that case, it's better to use the available trigger only as a "this contact is active, resync it" signal, and re-read the whole conversation from an API that returns both directions in a single pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why can timestamps stored in UTC appear wrong in an AI-generated answer?&lt;/strong&gt;&lt;br&gt;
Because if the model is left to mentally convert a UTC timestamp to local time, the calculation can be silently wrong. The reliable fix is converting the time server-side before passing it into the prompt, and explicitly stating to the model that the time is already local, so it doesn't attempt the conversion again on its own.&lt;/p&gt;

</description>
      <category>firestore</category>
      <category>vectorsearch</category>
      <category>netlify</category>
      <category>ai</category>
    </item>
    <item>
      <title>Bringing an External CRM's Chats into Firestore for AI Search: Vector Search, Webhooks, and a Stubborn Bundling Error</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Thu, 30 Jul 2026 10:55:59 +0000</pubDate>
      <link>https://dev.to/androve2k/bringing-an-external-crms-chats-into-firestore-for-ai-search-vector-search-webhooks-and-a-1geh</link>
      <guid>https://dev.to/androve2k/bringing-an-external-crms-chats-into-firestore-for-ai-search-vector-search-webhooks-and-a-1geh</guid>
      <description>&lt;p&gt;A client wanted their management panel's AI assistant to answer questions like &lt;em&gt;"what did this contact write me"&lt;/em&gt; or &lt;em&gt;"who replied about a certain topic"&lt;/em&gt;, pulling from thousands of conversations held on an external CRM. Between the idea and the first correct answer there was a bundling error that came back three times, a cursor that never advanced, and a phone number that pointed to the wrong contact.&lt;/p&gt;

&lt;p&gt;Here's the full path, bugs included.&lt;/p&gt;

&lt;h2&gt;
  
  
  What already existed, and what was really missing
&lt;/h2&gt;

&lt;p&gt;The client's management panel already had a solid integration with the external CRM used to handle WhatsApp, SMS, and email conversations with contacts: reading sales pipelines, opening chat with bubbles and attachments, sending replies. All reusable infrastructure, already in production for a while. One specific piece was missing: no function queried the CRM's full contact directory — only opportunities inside a specific pipeline — and, more importantly, there was no way at all for the panel's AI assistant to read past conversations.&lt;/p&gt;

&lt;p&gt;First rule, before writing a single new line of code: reuse what already worked. The function that read multiple pipeline stages in parallel, the modal that opened chat with full history, the function that sent messages — all proven and already in use. The one genuinely missing piece was a function able to search the entire contact directory, with text search and pagination, which the existing functions (built for single pipelines) didn't do.&lt;/p&gt;

&lt;h2&gt;
  
  
  A search that has to run across all contacts, not just the 30 loaded on screen
&lt;/h2&gt;

&lt;p&gt;With tens of thousands of contacts in the directory, loading them all client-side to filter isn't an option. The correct approach is server-side search: on every keystroke (debounced by 400ms to avoid hammering the API), the backend function forwards the query to the CRM's own search engine, which runs it against the entire archive — name, phone, email — not just the results already shown on screen. What the user sees, 30 contacts at a time with a "load more" button, is only the current page, not the search scope.&lt;/p&gt;

&lt;p&gt;A real limitation worth knowing: a third-party CRM's search is almost always a "contains" match, not fuzzy — it doesn't tolerate typos or a swapped first/last name. Worth keeping in mind both in the UI and, later in this story, in a piece of AI logic that broke for exactly this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request that changes the scale of the project
&lt;/h2&gt;

&lt;p&gt;At that point came the request that changed the nature of the work: the client wanted every contact and every conversation to also persist in their own database, kept up to date over time, queryable by the panel's AI assistant — no longer just fetched on the fly from the CRM when needed. With nearly 48,000 contacts and a chat history growing daily, this stopped being one more feature and became an architecture decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Firestore and not Realtime Database
&lt;/h2&gt;

&lt;p&gt;The first decision, and the one worth getting right: Realtime Database retransmits the entire node on every write, to every connected client. With tens of thousands of contacts and conversations that keep growing, a node like &lt;code&gt;contacts/{id}/messages&lt;/code&gt; would have become a bandwidth problem from the very first wave of writes. Firestore, by contrast, supports indexed queries, subcollections that grow without rewriting the parent document, and — the decisive reason here — &lt;strong&gt;native vector search&lt;/strong&gt;, exactly what's needed for semantic search across all conversations at once, without adding a separate vector database to pay for and manage.&lt;/p&gt;

&lt;p&gt;The data model is one document per contact (profile, tags, last sync) with a &lt;code&gt;messages&lt;/code&gt; subcollection for history — not an array inside the document itself. A contact with hundreds of messages would soon hit the one-megabyte-per-document limit, and more importantly every new message writes a single new document instead of rewriting the entire history each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The batched backfill, and the first obstacle: "Cannot find package firebase-admin"
&lt;/h2&gt;

&lt;p&gt;Writing and querying vector fields on Firestore reliably means using the official &lt;code&gt;firebase-admin&lt;/code&gt; SDK, not the raw REST API. That runs against a project convention — zero npm dependencies in the existing Netlify Functions — and the exception made itself felt on the very first deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cannot find package 'firebase-admin' imported from /var/task/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;despite the package being correctly declared in a dedicated &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cause is a function-bundling problem, not a code problem: the bundler simply wasn't packaging the dependency into the final zip.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First attempt&lt;/strong&gt; — explicitly setting the modern bundler via &lt;code&gt;node_bundler = "esbuild"&lt;/code&gt; in the config. Same error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Second attempt&lt;/strong&gt; — explicitly marking the package as "external" so it wouldn't be bundled (&lt;code&gt;external_node_modules&lt;/code&gt;), the documented standard fix for exactly this case. Again the identical error, a sign the problem was no longer bundling but the install step itself during the build.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The definitive fix, when the "clean" routes aren't enough.&lt;/strong&gt; No more half-measures: download the dependencies ahead of time and commit them already installed into the repository (so-called "vendoring" of &lt;code&gt;node_modules&lt;/code&gt;), so the platform finds them already there instead of having to install them during the build.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The interesting practical detail: the client works entirely from a browser, with no local terminal. The solution was a browser-accessible cloud dev environment (a "codespace") with a real built-in terminal: &lt;code&gt;npm install&lt;/code&gt; inside the functions folder, then commit and push the generated files straight from the interface — zero commands to install on the person's own computer. With &lt;code&gt;node_modules&lt;/code&gt; physically present in the repository, the next deploy went through on the first try.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cursor that never advanced (and the hidden cost of redoing the same work)
&lt;/h2&gt;

&lt;p&gt;With bundling solved, actually importing nearly 48,000 contacts can't be a single call: it has to run in batches, repeated by a function scheduled every few minutes, with a progress cursor saved on Firestore to resume exactly where it left off. The first real run revealed a subtler problem: the cursor stayed &lt;code&gt;null&lt;/code&gt; forever. The configured batch (25 contacts) simply couldn't finish within the function's time budget, so every execution restarted from the same initial contacts — not only failing to advance, but also re-paying for embedding every message that had already been embedded before, a silent and entirely avoidable cost.&lt;/p&gt;

&lt;p&gt;The fix had two parts: shrink the batch size to something that genuinely completes within the available time budget, and — more importantly — check which messages already had an embedding saved from a previous run, skipping them instead of regenerating them. With that fix, the cursor finally started advancing, and a rough estimate based on the observed pace put the full historical import at about a day and a half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-time messages: a "customer replied" trigger that isn't enough on its own
&lt;/h2&gt;

&lt;p&gt;For new messages, the CRM's automation offered a "customer replied"-style trigger — but with a non-obvious limitation: the variables available in the webhook body exposed the contact's identity, not the message text or its real timestamp. And, more importantly, no equivalent trigger existed for "the operator replied": automation engines of this kind react to customer actions, not to internal team actions.&lt;/p&gt;

&lt;p&gt;The sturdier fix wasn't chasing a trigger that probably doesn't exist, but changing how the available one gets used: the webhook no longer carries the message text, only the contact's identity, used as a signal meaning "this contact was just active, resync it now." The function it calls re-reads the entire fresh conversation from the same API the backfill already uses — which includes both inbound and outbound messages in the same fetch. One available trigger, full coverage on both directions. A second, lighter webhook, attached to contact-created/updated triggers, covers tag or profile changes that happen without a new message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dirty data in the pipeline: system events mistaken for messages
&lt;/h2&gt;

&lt;p&gt;An unplanned side effect: imported messages started including system events like "opportunity deleted", generated by the CRM whenever an opportunity changes stage — not text written by either side of the conversation. The fix, minimal but needed in three separate places (the real-time webhook, the shared sync function, and the backfill job already live in production), was to explicitly filter these events by type before saving them or generating an embedding for them.&lt;/p&gt;

&lt;p&gt;A practical reminder about this kind of change: an import job that's already live and running should be fixed with surgical patches, not rewritten for code cleanliness midway through — the risk of introducing a new bug while it's processing tens of thousands of records far outweighs the cosmetic benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Searching by phone, searching by name — and a silent ambiguity
&lt;/h2&gt;

&lt;p&gt;The first real test of AI search immediately exposed the limit of semantic search: searching for a phone number with the vector engine doesn't work, because the number doesn't appear in the &lt;em&gt;meaning&lt;/em&gt; of the message text. What was needed was a direct identity lookup, not a content search — two different tools for two different questions.&lt;/p&gt;

&lt;p&gt;The first attempt at a phone lookup produced a sneakier bug: a ten-digit local number that happened, by pure coincidence, to start with the same digits as the country code was mistaken for an already-complete international number — and the search stopped at the first match found, returning the wrong contact with no warning at all. The correct fix wasn't "guess better" but checking every plausible variant in parallel (with and without the country code) and, if different contacts turn up, explicitly flagging the ambiguity instead of picking one at random. The same principle was then extended to a direct lookup by exact name and by tag, both with an automatic, silent fallback to semantic search whenever the direct match finds nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong time zone in the AI's answer
&lt;/h2&gt;

&lt;p&gt;An easy detail to underestimate: timestamps were correctly stored in UTC, as they should be, but were passed to the language model as-is, leaving it to mentally convert them to local time in its final answer. The result was silently off by two hours, with no error to catch — a message from 1:22 PM local time was presented as 11:22 AM.&lt;/p&gt;

&lt;p&gt;The reliable fix wasn't a more insistent prompt instruction, but removing that calculation from the model entirely: converting the timestamp server-side to the correct time zone (automatically handling daylight saving as well) before injecting it into the context, and explicitly stating that the time received is already local, so the model doesn't attempt the conversion again on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long an AI answer should be
&lt;/h2&gt;

&lt;p&gt;One last problem, more mundane but frequent: some answers with long lists of messages were getting cut off halfway, with the last item — often the most recent and the most relevant one — missing. The cause was simply a response token budget too low for the language model. The fix has three layers, not just one: raise the maximum response token budget, order lists from most recent message to oldest (so any future truncation drops the least relevant data, not the freshest), and explicitly instruct the model to stay compact in its formatting, so it doesn't waste response headroom on unnecessary decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take away from this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reusing existing infrastructure saves real time&lt;/strong&gt;, but changing scale (from a few on-the-fly reads to tens of thousands of persistent, queryable records) is always an architecture decision, not just a code one — worth stopping to decide explicitly before writing the first line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dependency exception needs a plan B ready in advance.&lt;/strong&gt; If standard bundling doesn't work on the first try, manually vendoring dependencies is a slow but reliable path, and it's better to know that before deploy day, not during it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity search and semantic search are two different tools&lt;/strong&gt;, not one tool with two modes. Using the wrong one produces a result that looks plausible but is silently incorrect — the hardest kind of bug to spot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The most annoying bugs were never the "big" decisions&lt;/strong&gt; — Firestore or RTDB, vector or text search — but the small, silent details: a time budget too tight, a time zone left unconverted, a token limit set too low. None of these throws a visible error: they just produce a wrong answer that looks right.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I write about Firebase, Netlify, PWAs, and AI applied to real production projects on &lt;a href="https://roversia.it/blog.html" rel="noopener noreferrer"&gt;roversia.it/blog&lt;/a&gt;. This post was originally published there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>ai</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>When a Public API Stops Working: CORS, an Abandoned NASA Dataset, and a Netlify Function Proxy</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Tue, 28 Jul 2026 03:44:15 +0000</pubDate>
      <link>https://dev.to/androve2k/when-a-public-api-stops-working-cors-an-abandoned-nasa-dataset-and-a-netlify-function-proxy-22n4</link>
      <guid>https://dev.to/androve2k/when-a-public-api-stops-working-cors-an-abandoned-nasa-dataset-and-a-netlify-function-proxy-22n4</guid>
      <description>&lt;p&gt;I wanted to build a page aggregating real-time space data from three free public sources: astronomy photos, the Space Station’s position, upcoming launches. In theory, three &lt;code&gt;fetch()&lt;/code&gt; calls and done. In practice each of the three sources broke in a different way, and none of the three ways was what I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three sources, three different categories of risk
&lt;/h2&gt;

&lt;p&gt;The idea was simple: zero server costs, zero keys to manage where possible, data read straight from the browser. The page needed to show the astronomy picture of the day and Mars rover images, the International Space Station’s real-time position, and upcoming launches with rocket and reusable-booster data — three categories, three different providers, all documented as “free public API, no complicated signup”.&lt;/p&gt;

&lt;p&gt;I assumed “public and free” also meant “callable directly from a web page.” That’s not true by definition, and it was the first thing that surfaced once the page went live.&lt;/p&gt;

&lt;h2&gt;
  
  
  CORS blocked: when the problem isn’t in your code
&lt;/h2&gt;

&lt;p&gt;The launches section returned a console error that was as clear as it was unhelpful at first glance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No 'Access-Control-Allow-Origin' header is present on the requested resource
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key detail is that this check isn’t done by the API’s server — it’s done by the browser of whoever is visiting the site. If the server doesn’t include that header in the response, the browser discards the response before it ever reaches the JavaScript code, even though the requested data arrived just fine. An identical request made from a terminal works without a hitch, because CORS is a rule that only exists in a browser’s context.&lt;/p&gt;

&lt;p&gt;The standard fix is to move the call server-side: a small Netlify Function that queries the third-party API and returns the response with the correct CORS headers pointed at your own domain. The user’s browser only talks to your own function, not to the remote API, so the browser-side CORS check simply no longer applies to that part of the path.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Almost never enough to stop there. I wrote the proxy, deployed it, and the section kept returning errors — just with a different code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The proxy works, but the response is still a 502
&lt;/h2&gt;

&lt;p&gt;With the proxy in place the CORS block was gone, but requests came back with a &lt;code&gt;502 Bad Gateway&lt;/code&gt;: the function was reaching the remote API correctly, but it wasn’t responding usefully. Before digging further into my own code, I checked the status of the open source project powering that API. The GitHub repository had been archived about a month earlier — read-only, no new commits possible. In the meantime some endpoints had silently migrated to a new version while others stayed on the old one, and the infrastructure behind the API showed signs of broken connectivity, not just slowness.&lt;/p&gt;

&lt;p&gt;An archived repository is as strong a signal as a changelog entry: nobody is triaging issues anymore, nobody is shipping fixes. Continuing to debug against a service in that state is time spent chasing a target that might stop existing entirely in the meantime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replacing the source, not just the code
&lt;/h2&gt;

&lt;p&gt;The sturdier choice at that point wasn’t another round of patches but switching providers. I found a launch-tracking API that’s professionally and actively maintained, with a different but comparable data shape: the same ability to list future and past launches, the same access to rocket configurations, and — a detail I hadn’t taken for granted — tracking of individual reusable boosters too, with serial number and status, the exact equivalent of what the discontinued API offered.&lt;/p&gt;

&lt;p&gt;Before writing a single line of code I verified the data schema with a real live call, instead of trusting the documentation alone: a ten-minute check that avoided another round of “ship it, someone reports the bug, fix it.”&lt;/p&gt;

&lt;h2&gt;
  
  
  A 404 that actually means “closed for good”
&lt;/h2&gt;

&lt;p&gt;In parallel, the Mars rover photos section had stopped returning results: the main endpoint responded 404, and even the fallback endpoint I’d written for exactly this situation responded 404 too. At that point the suspicion shifted from “malformed request” to “the service no longer exists,” and the confirmation came from a place I hadn’t checked first: the official data catalog linked to that API stated in plain text that the dataset contained no data, maintained by a single external developer, untouched for over a year.&lt;/p&gt;

&lt;p&gt;The practical difference between an isolated 404 and a structural one sits right here: a single error on one endpoint, maybe intermittent, is often a temporary hiccup. Multiple related endpoints returning the same error consistently, confirmed by an independent source like an official data catalog, tell a different story — an abandoned service, not a service outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  A replacement that slightly changes what the page promises
&lt;/h2&gt;

&lt;p&gt;In place of the discontinued service I connected the same agency’s official image library, a different, actively maintained piece of infrastructure — but with a slightly different content nature: from “the latest photo the rover took in the last few hours” to “relevant images from the official archive, filtered by rover and sorted by date.” Same real photos, same institutional source, but no longer a snapshot of the exact moment.&lt;/p&gt;

&lt;p&gt;The temptation in these cases is to pretend nothing changed and keep presenting the section as “latest photos.” I chose to state it explicitly next to the selector instead, with a line explaining why these aren’t strictly the most recent photos anymore: an honestly disclosed limitation is far less of a problem than an unexplained result the user has to figure out on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quieter bug: when structured data doesn’t tell the truth
&lt;/h2&gt;

&lt;p&gt;In the final review pass I found two problems that had nothing to do with the external APIs. The first was simple: the meta description comfortably exceeded the roughly 155-160 characters Google shows before truncating awkwardly in search results. The second was sneakier: three of the six FAQPage structured-data question/answer pairs had slightly different wording than the text actually visible on the page — differences born from writing the two blocks in separate passes, invisible on a quick re-read.&lt;/p&gt;

&lt;p&gt;Search engines expect FAQPage structured data to faithfully mirror what a visitor actually sees, not a summary or a paraphrase: even a small mismatch can make the content ineligible for the rich result. I found it with a small script that programmatically compares the two versions word for word, not by eyeballing it.&lt;/p&gt;

&lt;p&gt;(While writing this very recap, I made the exact same mistake once — a nice reminder that this class of bug doesn’t announce itself.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take away from this
&lt;/h2&gt;

&lt;p&gt;The most useful lesson isn’t about a single error, but about an order in which to verify things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A real call always beats documentation, especially for a community-maintained project.&lt;/li&gt;
&lt;li&gt;A proxy fixes CORS but doesn’t guarantee the uptime of the service downstream — two separate problems even when they show up at the same point in the code.&lt;/li&gt;
&lt;li&gt;An archived repository is already an answer, no need to wait for the API to formally stop responding too.&lt;/li&gt;
&lt;li&gt;Structured data needs to be checked against visible content automatically, because small wording differences almost always slip past a manual read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re interested in the same server-side proxy pattern applied to a different case, I also wrote about &lt;a href="https://roversia.it/blog-22-riassunto-pdf-gemini-flash-lite.html" rel="noopener noreferrer"&gt;summarizing PDFs with Gemini Flash-Lite via a Netlify Function&lt;/a&gt;. And if the topic is more generally about integrating an external API into a vanilla JS project, there’s also the article on the &lt;a href="https://roversia.it/blog-09-assistente-ai-gemini-gestionale.html" rel="noopener noreferrer"&gt;Gemini API chatbot inside a management dashboard&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I originally published this on &lt;a href="https://roversia.it/blog-24-api-pubbliche-cors-proxy-netlify-function.html" rel="noopener noreferrer"&gt;my site&lt;/a&gt;, where you can also try the &lt;a href="https://roversia.it/spazio.html" rel="noopener noreferrer"&gt;live Space page&lt;/a&gt; this post is about.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>api</category>
    </item>
    <item>
      <title>Removing a Photo's Background in the Browser, With No Upload: AI Licenses, ONNX Models, and a Frozen Tab</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Fri, 24 Jul 2026 12:36:06 +0000</pubDate>
      <link>https://dev.to/androve2k/removing-a-photos-background-in-the-browser-with-no-upload-ai-licenses-onnx-models-and-a-1cc0</link>
      <guid>https://dev.to/androve2k/removing-a-photos-background-in-the-browser-with-no-upload-ai-licenses-onnx-models-and-a-1cc0</guid>
      <description>&lt;p&gt;I wanted to add a background-removal tool to my site's image cluster that stayed true to the 100% client-side processing principle I already use for PDFs and image conversions. The path there was anything but linear: a library dropped over a licensing problem, a carefully chosen model that turned out more limited than expected, and a bug that froze the &lt;em&gt;entire&lt;/em&gt; page — not just the tool — during computation.&lt;/p&gt;

&lt;p&gt;Here's the full build, including the parts that didn't work the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting problem: what's actually feasible for free?
&lt;/h2&gt;

&lt;p&gt;The initial idea was broad: remove backgrounds, and maybe unwanted objects too. The two tasks have very different difficulty levels.&lt;/p&gt;

&lt;p&gt;Removing objects requires inpainting — plausibly reconstructing the erased area — which in practice still means heavy generative models, impractical to run client-side with good quality on an average device.&lt;/p&gt;

&lt;p&gt;Removing a &lt;em&gt;background&lt;/em&gt;, on the other hand, is a segmentation problem: separating a subject from its surroundings. That has much lighter models available, runnable entirely via WebAssembly with no server involved at all.&lt;/p&gt;

&lt;p&gt;So: background removal only, object removal shelved for later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AGPL trap
&lt;/h2&gt;

&lt;p&gt;The first library that looked like a perfect fit turned out to be distributed under &lt;strong&gt;AGPL&lt;/strong&gt;, a strong copyleft license. Free to use — but with a real catch for anyone embedding it in a public, closed-source web service: AGPL can require releasing the full source of the project that embeds it, under the same license.&lt;/p&gt;

&lt;p&gt;"Free for the end user" and "safe to drop into a closed-source commercial product" are two different questions, and it's worth answering the second one &lt;em&gt;before&lt;/em&gt; writing integration code, not after deploying it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Before wiring any "free" AI library into a commercial project, check the exact license, not just the price tag. AGPL, GPL, and other strong copyleft licenses are fine for personal or internal tools, risky for a public closed-source product.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix: switch to &lt;strong&gt;Transformers.js&lt;/strong&gt; — Hugging Face's library for running ML models in the browser on top of ONNX Runtime Web — with a permissively licensed model (Apache-2.0) instead of the AGPL wrapper. Same principle (an ONNX model pulled once from a CDN, then cached by the browser), clean license.&lt;/p&gt;

&lt;h2&gt;
  
  
  A light CNN, not a heavy transformer
&lt;/h2&gt;

&lt;p&gt;Picking the model taught me a lesson that generalizes to any browser-ML project: &lt;strong&gt;architecture matters more than file size&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A transformer-based model, even a qualitatively great one with a permissive license, can crash from memory exhaustion during WASM inference on a lot of machines. Transformer attention scales memory with the number of image "patches," and at normal photo resolution the intermediate tensors get huge. A smaller CNN with an architecture built for segmentation runs far more reliably in WASM.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;Transformers.js on top of ONNX Runtime Web (WASM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default model&lt;/td&gt;
&lt;td&gt;Light CNN, Apache-2.0, optimized for people&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optional model&lt;/td&gt;
&lt;td&gt;Heavier transformer, MIT, more general-purpose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Processing&lt;/td&gt;
&lt;td&gt;100% local, no image upload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache&lt;/td&gt;
&lt;td&gt;Browser + service worker, one-time download&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The "raw matte" problem
&lt;/h2&gt;

&lt;p&gt;The model often left a residual color halo around the cutout, because the output is a &lt;em&gt;soft&lt;/em&gt; alpha matte rather than a clean binary mask — background pixels could carry residual alpha values of 5–15% instead of a clean 0.&lt;/p&gt;

&lt;p&gt;No second model needed. Just post-process the alpha curve: force low values to full transparency, force high values to full opacity, use a smoothstep transition in between so soft edges (hair, fine detail) survive. On a test image this pushed over 60% of pixels to fully transparent and over 35% to fully opaque, leaving only a small sliver in the mid-range — a clean result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified alpha cleanup&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cleanAlpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lowT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;highT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;lowT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;highT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lowT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;highT&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lowT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// smoothstep&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The real limit: a specialized model isn't a general one
&lt;/h2&gt;

&lt;p&gt;First real-world tests were disappointing on two fronts: a flat illustration (the model only trimmed the margin around the shape, leaving the whole drawing opaque) and a dim, cluttered indoor photo (the model nearly erased the main subject too).&lt;/p&gt;

&lt;p&gt;To isolate the cause, I temporarily added a debug panel showing the model's &lt;em&gt;raw&lt;/em&gt; mask, before any alpha cleanup, next to the final result. They were nearly identical — so the bug wasn't in my post-processing, it was upstream, in the model itself.&lt;/p&gt;

&lt;p&gt;Checking the model card confirmed it: the default model was trained specifically on a &lt;strong&gt;human segmentation dataset&lt;/strong&gt;, not a general-purpose one. On a well-lit, close-up portrait, the cutout is genuinely clean, hair included. On anything far from that training domain — illustrations, dark scenes, cluttered framing — quality drops in a predictable, not-a-bug way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A model that's "light and WASM-reliable" is often exactly that &lt;em&gt;because&lt;/em&gt; it's trained on a narrow domain. Before calling a model "bad," check what it was actually trained on — the mismatch is usually domain, not quality.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  An optional "better model" button, not a default swap
&lt;/h2&gt;

&lt;p&gt;Instead of replacing the light model with a heavier, more general one for &lt;em&gt;everyone&lt;/em&gt; — making every user pay the load-time cost for a case they may never hit — I added a second model (an MIT-licensed transformer, better suited to generic scenes) available only on request. A "Try the better model" button appears after the first result and reprocesses the same image, without overwriting the existing output until the heavier model succeeds. If it fails from insufficient memory, the first result stays intact and the user gets a clear error instead of a broken tool.&lt;/p&gt;

&lt;p&gt;Light by default, heavy on explicit request — nobody pays for a download they'll never use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real bug: the main thread freezes during inference
&lt;/h2&gt;

&lt;p&gt;Testing the heavier model surfaced something much worse than mediocre output: during processing, the &lt;em&gt;entire page&lt;/em&gt; stopped responding — not just the tool, the top nav links too.&lt;/p&gt;

&lt;p&gt;The cause: by default, ONNX Runtime Web runs WASM computation on the browser's &lt;strong&gt;main thread&lt;/strong&gt;, the same one handling clicks, scroll, and rendering. During heavy inference that thread stays busy and the whole UI freezes.&lt;/p&gt;

&lt;p&gt;First attempt at a fix — flipping an internal library option meant to delegate computation to a separate thread — didn't work, likely due to delicate initialization timing. The reliable fix was writing and controlling a real &lt;strong&gt;dedicated Web Worker&lt;/strong&gt; myself: a separate thread that loads the library, downloads the model, and runs the whole inference, talking to the main page only through &lt;code&gt;postMessage&lt;/code&gt; (input image in, progress and result out).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// worker.js (simplified)&lt;/span&gt;
&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;imageBuffer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://cdn.jsdelivr.net/npm/@huggingface/transformers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remover&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;background-removal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MODEL_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;device&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wasm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;remover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageBuffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way the main thread never does heavy computation &lt;em&gt;by construction&lt;/em&gt;, not by a configuration flag I was hoping would hold.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If client-side AI processing freezes the whole interface, not just the component using it, the top suspect is main-thread computation. A config flag might not be enough — a dedicated, explicitly written Web Worker is the more reliable fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Handling a real two-minute wait
&lt;/h2&gt;

&lt;p&gt;Even with the freeze fixed, there was a perception problem left: the heavier model takes roughly two minutes on common hardware, and the library doesn't expose real percentage progress during inference — only during the model download.&lt;/p&gt;

&lt;p&gt;A progress bar sitting "full and still" for two minutes reads as &lt;em&gt;broken&lt;/em&gt;, not &lt;em&gt;slow&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Final approach: a bar that advances on an estimated curve over ~2.5 minutes, with elapsed seconds shown below, and — if processing runs past that estimate — an automatic switch to an indeterminate animation (a continuously scrolling stripe), the universal "still working" signal without faking a percentage the model can't provide. A wider safety timeout unlocks the UI if something really goes wrong, so the user can retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away from this
&lt;/h2&gt;

&lt;p&gt;The most useful lesson isn't about one bug — it's about ordering priorities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Check the license before you check the quality.&lt;/li&gt;
&lt;li&gt;Check a model's training domain before you judge its output.&lt;/li&gt;
&lt;li&gt;Don't trust a library flag for something as critical as "doesn't block the main thread" — verify it with a real test, don't assume it holds.&lt;/li&gt;
&lt;li&gt;A clearly communicated limitation ("works best on well-lit close-up photos") is far less frustrating for users than an unexplained bad result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've hit the AGPL-vs-permissive-license question before, or fought with &lt;code&gt;onnxruntime-web&lt;/code&gt; blocking a UI thread, I'd be curious to hear how you solved it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about building and maintaining &lt;a href="https://roversia.it" rel="noopener noreferrer"&gt;roversia.it&lt;/a&gt; — a personal site with 35+ browser tools, PWA games, and small web apps, all vanilla JS, zero monthly cost, and (as much as possible) zero server-side processing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>machinelearning</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Summarizing a PDF with AI for less than a cent: PDF.js, Gemini Flash-Lite, and a Netlify Function</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Wed, 22 Jul 2026 07:56:33 +0000</pubDate>
      <link>https://dev.to/androve2k/summarizing-a-pdf-with-ai-for-less-than-a-cent-pdfjs-gemini-flash-lite-and-a-netlify-function-2bi0</link>
      <guid>https://dev.to/androve2k/summarizing-a-pdf-with-ai-for-less-than-a-cent-pdfjs-gemini-flash-lite-and-a-netlify-function-2bi0</guid>
      <description>&lt;p&gt;I wanted to add a tool to my site's PDF cluster that summarizes uploaded documents and contracts, without blowing up costs or breaking the zero-heavy-server-processing principle I follow for every other tool. Here's the architecture I picked, the real economics behind an AI-generated summary, and an authentication snag I didn't expect: Google's newer API keys use a different format than the one I'd always worked with, and they silently break the most common authentication method used in tutorials and existing code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: what does it actually cost to have an AI read a PDF?
&lt;/h2&gt;

&lt;p&gt;The starting idea was simple: a tool where the user uploads a PDF — a contract, a report, a long document — and gets back a structured summary in a few seconds. The rest of the site's PDF cluster (merge, split, compress, OCR) already processes everything client-side, at zero cost. An AI summary is different by nature: it necessarily needs a call to a language model, and that has a real per-token cost. Before writing a single line of code, the real question was: what does this cost me at scale, if the tool actually gets used?&lt;/p&gt;

&lt;p&gt;With a cheap model like &lt;strong&gt;Gemini Flash-Lite&lt;/strong&gt;, the numbers were more reassuring than I expected. For a twenty-page-ish contract (roughly 13,000 input tokens plus the instruction prompt, and a 500-token summary in output), a single call costs on the order of a few thousandths of a dollar — most of the cost comes from input volume, priced far lower than output tokens. Even much longer documents stay under one or two cents. On a monthly basis, even with sustained traffic for a personal site, the worst-case spend stays in the tens of euros.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The real cost risk isn't normal use, it's abuse: someone looping the call or repeatedly uploading huge documents. That's why I built in a rate limit from day one, not as a later optimization.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Architecture: browser-side extraction, server-side AI processing
&lt;/h2&gt;

&lt;p&gt;The decision that shaped everything else was to keep text extraction client-side, reusing the same PDF parsing library already used elsewhere in the site's PDF tools, and to send the serverless function only clean text, never the binary file.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Text extraction&lt;/td&gt;
&lt;td&gt;PDF.js, entirely in the browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Netlify Function, receives text only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI model&lt;/td&gt;
&lt;td&gt;Gemini Flash-Lite via REST API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate limiting&lt;/td&gt;
&lt;td&gt;Per-IP/day counter on Firestore, via Admin SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Document storage&lt;/td&gt;
&lt;td&gt;None — processed on the fly, nothing is saved&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The main reason isn't only cost: serverless functions have tight limits on payload size and execution time. A heavy scanned PDF could easily blow past them. By extracting text in the browser, the function receives a small, predictable payload, the token cost is lower since there's no binary markup to process, and timeout risk drops close to zero. For scanned PDFs (image, not real text), OCR remains available as an optional preliminary step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting without forcing a login
&lt;/h2&gt;

&lt;p&gt;I didn't want to put a login wall in front of a tool meant for quick, occasional use. The solution is a per-IP daily counter, saved server-side with the same service credentials already used by the site's other functions, checked before every model call and incremented right after. Once a daily threshold is hit, the function returns an explicit error instead of calling the AI.&lt;/p&gt;

&lt;p&gt;One detail that made me stop and think: the database security rules stay at &lt;code&gt;allow read, write: if false&lt;/code&gt; for all direct client traffic, with no need to add an exception for the new counter collection. Serverless functions use the Admin SDK with service credentials, which always bypasses the rules — so the global "deny all" automatically covers a collection created later too, with no changes needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unexpected snag: an API key in a format I'd never seen
&lt;/h2&gt;

&lt;p&gt;When it came time to actually wire up the API key, I got one starting with a different prefix than what I'd always worked with in previous projects. My first instinct was to assume a mistake — maybe an OAuth token copied by accident instead of a real API key.&lt;/p&gt;

&lt;p&gt;It wasn't a mistake: it's a newer "Auth Key" format that the latest key-creation interfaces generate by default. The real problem, though, was technical: keys in the new format don't work when passed as a &lt;code&gt;?key=&lt;/code&gt; URL parameter — the most common method in tutorials and in already-written code — and return an authentication error. They expect a dedicated HTTP header instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: breaks with the newer key format&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`https://generativelanguage.googleapis.com/v1beta/models/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:generateContent?key=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After: works with both the new and the previous key format&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`https://generativelanguage.googleapis.com/v1beta/models/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:generateContent`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-goog-api-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a project suddenly stops authenticating with Google after regenerating a key, this is the first thing worth checking.&lt;/p&gt;

&lt;p&gt;A side note on operational security: during debugging, the key was accidentally pasted in plain text into a chat. Even in a private channel, a key seen by anyone else (or by another system) should be treated as potentially compromised: the correct move is to rotate it immediately, not to keep using it because "no one really saw it."&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the key before deploy, not after
&lt;/h2&gt;

&lt;p&gt;Instead of wiring the regenerated key straight into the production function, I prepared an isolated Node.js script that makes a single test call to the model and prints only the outcome and the model name — never the key itself. On Windows, the only snag was remembering that the syntax for setting an environment variable in PowerShell differs from bash: two separate commands instead of one line.&lt;/p&gt;

&lt;p&gt;Once I confirmed the key worked and that the model name was actually available for the account, wiring it into Netlify as a function-scoped environment variable was the last, uneventful step.&lt;/p&gt;

&lt;h2&gt;
  
  
  From a "form-only" page to an indexable tool
&lt;/h2&gt;

&lt;p&gt;The first version of the tool page was, in practice, almost just a file upload form: functional for someone who already knows what to do, but a problem for organic search. A page with little indexable text around the form gets classified as "thin content" — search engines don't have enough signal to understand what it's about or which queries to show it for.&lt;/p&gt;

&lt;p&gt;I added: hreflang tags, structured data for &lt;code&gt;BreadcrumbList&lt;/code&gt; and &lt;code&gt;FAQPage&lt;/code&gt; (on top of the existing &lt;code&gt;WebApplication&lt;/code&gt; schema), a three-step "How it works" section, a visible FAQ matching the structured data content, and internal links to related tools. All validated before shipping: balanced markup, all JSON-LD blocks parsing correctly, no credentials in the code.&lt;/p&gt;

&lt;p&gt;One almost comic detail: after wiring up the tool, I noticed it was missing the animated canvas background present on every other page of the site. The cause was trivial — I'd used a plain &lt;code&gt;&amp;lt;div id="canvas-container"&amp;gt;&lt;/code&gt; in the page instead of the &lt;code&gt;&amp;lt;canvas id="canvas"&amp;gt;&lt;/code&gt; element the shared animation script explicitly looks up by &lt;code&gt;id&lt;/code&gt;. A reminder of how a small detail, copied wrong from a template, can go unnoticed until someone spots it by eye.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take away from this project
&lt;/h2&gt;

&lt;p&gt;The most useful lesson isn't strictly technical: a "free for the user" tool is never truly zero-cost for whoever maintains it, and it's worth doing the math before writing code, not after. In my case the numbers confirmed the project was sustainable — but rate limiting is still the first piece I wrote, not the last. The same goes for operational security: a key seen by an extra pair of eyes gets rotated, full stop, without calculating how "likely" it is that it was actually used.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://roversia.it/blog-22-riassunto-pdf-gemini-flash-lite.html" rel="noopener noreferrer"&gt;roversia.it&lt;/a&gt;, where I write about the vanilla JS/Firebase/Netlify stack behind my personal projects.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
      <category>serverless</category>
    </item>
    <item>
      <title>PanelControl: a 65-file business app, in vanilla JavaScript with no framework</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Mon, 20 Jul 2026 08:44:50 +0000</pubDate>
      <link>https://dev.to/androve2k/panelcontrol-a-65-file-business-app-in-vanilla-javascript-with-no-framework-1618</link>
      <guid>https://dev.to/androve2k/panelcontrol-a-65-file-business-app-in-vanilla-javascript-with-no-framework-1618</guid>
      <description>&lt;p&gt;PanelControl is an internal business app I designed and maintain solo for an official myPOS reseller: sales, onboarding, shipping, staff shifts, HR, and administration, all in one multi-role Progressive Web App. It started as a single HTML file. It's now 65+ source files and about thirty serverless functions — and it's still framework-free, with no build step.&lt;/p&gt;

&lt;p&gt;Here's what kept it standing as it grew.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Before PanelControl, the business ran on spreadsheets, WhatsApp, email, and phone calls. Dozens of orders a day, hundreds of sales leads, contract activations, shipments, shifts, time off, payroll, plus a steady stream of leads from an external CRM — no single view, no reliable history, no way to know in real time what a colleague was doing.&lt;/p&gt;

&lt;p&gt;I built a single PWA that centralizes all of it, installable on desktop and mobile, used daily on tablets and phones by the team.&lt;/p&gt;

&lt;h2&gt;
  
  
  A hand-written rendering engine
&lt;/h2&gt;

&lt;p&gt;No React, no Vue. A global state object holds every piece of application data. Every state change triggers a debounced &lt;code&gt;render()&lt;/code&gt; (80ms, to avoid cascading re-renders during closely spaced writes), plus a &lt;code&gt;renderNow()&lt;/code&gt; for cases that need an immediate synchronous update.&lt;/p&gt;

&lt;p&gt;The part I'm proudest of: a &lt;strong&gt;user-interaction guard&lt;/strong&gt;. Before every re-render, the engine checks whether focus is on an input field, and if so, postpones the render by a few seconds so a realtime update from Firebase never wipes out a form someone is filling in — with explicit exceptions for controls that must stay reactive (month selectors, checkboxes).&lt;/p&gt;

&lt;p&gt;Not every module loads on first launch either. Heavy modules load on-demand when the user navigates to that section, with a retry loop if the module hasn't arrived over the network yet — important since the app also runs on tablets over spotty mobile connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  From &lt;code&gt;on('value')&lt;/code&gt; to granular listeners
&lt;/h2&gt;

&lt;p&gt;The single most impactful optimization in the project: migrating Firebase Realtime Database listeners from &lt;code&gt;on('value')&lt;/code&gt; to granular &lt;code&gt;child_added&lt;/code&gt; / &lt;code&gt;child_changed&lt;/code&gt; / &lt;code&gt;child_removed&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before:&lt;/strong&gt; every single write to a growing node caused &lt;em&gt;every&lt;/em&gt; connected client to re-download the entire history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After:&lt;/strong&gt; only the changed delta gets transmitted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measured result:&lt;/strong&gt; an estimated 40–60 MB/day saved in Firebase bandwidth, with a direct impact on pay-as-you-go costs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same pattern applied to the activity log cut the initial page load from 200 to 50 records via &lt;code&gt;once('value')&lt;/code&gt;, followed by a &lt;code&gt;child_added&lt;/code&gt; listener for new events only — about 75% bandwidth saved on that section alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication without shared credentials
&lt;/h2&gt;

&lt;p&gt;The login system was rebuilt to eliminate shared Firebase credentials on the client. A Netlify Function verifies username and password with PBKDF2-SHA256 hashing, applies server-side rate limiting against brute force, and returns a server-minted Firebase Custom Token. The client exchanges it for an authenticated session, with permissions mapped via custom claims and checked in the database's security rules.&lt;/p&gt;

&lt;p&gt;For granular, per-operator permissions on top of the base role, I settled on one non-negotiable pattern for every access check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;access = legacy_hardcoded_list.includes(operator)
      OR hasPermission(operator, feature)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never the dynamic check alone. This avoids breaking access for operators not yet explicitly migrated to the new permission system — a rule I've applied across every permission change in the project since: new rules OR with the old ones, they never replace them outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few modules that raised real problems
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Internal chat.&lt;/strong&gt; A floating bubble UI hit the classic &lt;code&gt;position:fixed&lt;/code&gt; bug: it stops working correctly once an ancestor has a CSS &lt;code&gt;transform&lt;/code&gt; applied, which is common when nesting modals. Fixed by making the chat a direct sibling of the main container instead of a descendant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mail.&lt;/strong&gt; Polls the Gmail API instead of using push, with a denormalized schema split across two nodes to balance list speed against detail speed. Listeners here are always granular — the nodes hold potentially heavy email bodies, and &lt;code&gt;on('value')&lt;/code&gt; would redownload the whole mailbox on any tiny change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Call history.&lt;/strong&gt; A two-speed architecture: the current month stays on the Realtime Database with a capped listener, past months move to Firestore with block pagination. Searches always route to Firestore to avoid saturating the realtime database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CRM reconciliation.&lt;/strong&gt; A scheduled function checks each lead against the external CRM one call at a time (not a bulk dump) to stay within rate limits, rechecking recent leads every 24 hours and permanently skipping older ones once they've settled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail-soft serverless functions
&lt;/h2&gt;

&lt;p&gt;Backend functions are pure ESM, deliberately dependency-free. A few shared patterns across every webhook integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deduplication via a business key (order number), not a CRM-generated ID&lt;/li&gt;
&lt;li&gt;Every webhook logs to both RTDB and Firestore, for realtime visibility and historical queries&lt;/li&gt;
&lt;li&gt;Critical alerts go out via Telegram with per-category throttling&lt;/li&gt;
&lt;li&gt;Webhooks always respond HTTP 200, even on an internally handled error — so the external CRM doesn't retry the same request forever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One operational constraint worth knowing: Netlify environment variables cap out at 4KB per function. Large credentials like private keys end up hardcoded in the function file instead, with a note for manual rotation, rather than blowing the limit and breaking the deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;"No framework" doesn't mean "no discipline." It means writing by hand the rules a framework would otherwise give you for free — a sensible debounce, a user-interaction guard, a non-destructive permission pattern — and sticking to them as the codebase grows from one file to sixty-five.&lt;/p&gt;

&lt;p&gt;I wrote a longer, more detailed version of this case study, plus related deep-dives on the Custom Token migration and a cross-app HMAC token, on my site: &lt;a href="https://roversia.it/blog-21-panelcontrol-gestionale-vanilla-js-firebase.html" rel="noopener noreferrer"&gt;roversia.it/blog-21&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>firebase</category>
      <category>webdev</category>
      <category>pwa</category>
    </item>
    <item>
      <title>Free Online Burraco, From Scratch: Rules, Wildcards, and a Three-Tier AI in Vanilla JavaScript</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Fri, 17 Jul 2026 06:27:46 +0000</pubDate>
      <link>https://dev.to/androve2k/free-online-burraco-from-scratch-rules-wildcards-and-a-three-tier-ai-in-vanilla-javascript-5fp</link>
      <guid>https://dev.to/androve2k/free-online-burraco-from-scratch-rules-wildcards-and-a-three-tier-ai-in-vanilla-javascript-5fp</guid>
      <description>&lt;p&gt;I wanted a real burraco game, playable right away against the computer, with no sign-up and none of the intrusive ads that clutter most free alternatives online. Here's how I wrote the validation for sets and runs with wildcards, pot and hand-closing logic, and a three-tier AI opponent — all with no framework, no game library, and no build step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why burraco, and why from scratch
&lt;/h2&gt;

&lt;p&gt;Burraco is among the most searched card games online in Italy, yet good free options are scarce: many sites require an account just to start, others push straight toward paid multiplayer, others are so full of banners the match becomes unreadable on mobile. I wanted the opposite — open the page, the computer shuffles, you play. Like the other games on my site, it's all vanilla JavaScript: no card library, no engine, just DOM, CSS, and game logic.&lt;/p&gt;

&lt;p&gt;The interesting part isn't the interface — cards, animations, layout are mechanical work — but the rules themselves. Burraco has a set of constraints that look simple until you have to turn them into code that must always work, including the edge cases: how many runs can form with two wildcards at the ends, when the pot can be opened, what happens if nobody closes and the deck runs out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating sets and runs, wildcards included
&lt;/h2&gt;

&lt;p&gt;The two valid melds are &lt;strong&gt;sets&lt;/strong&gt; (3+ cards of the same rank) and &lt;strong&gt;runs&lt;/strong&gt; (3+ cards in sequence, same suit). 2s and jokers are wild and can replace any card in either. For a set the rule is straightforward: separate real cards from wilds, check that all real cards share the same rank, and require wilds not to outnumber real cards — otherwise the set would become "mostly wild," which classic rules don't allow.&lt;/p&gt;

&lt;p&gt;The run is trickier. I take the real cards of the same suit, compute the &lt;strong&gt;span&lt;/strong&gt; between the lowest and highest rank, and the internal "gaps" left to fill are the difference between the span and the number of real cards. If the available wilds can't cover those gaps, the run is invalid. If wilds are left over after filling internal gaps, they can extend the run past either end — but only if there's "room" toward the ace or the king, since the deck doesn't wrap around.&lt;/p&gt;

&lt;p&gt;The case that cost me the most time: a run starting at 9 and ending at king, with one leftover wild. That wild can only extend downward (9→8) because there's no room above the king. If you don't explicitly handle the asymmetry between "room below" and "room above" the span, the validator accepts impossible runs.&lt;/p&gt;

&lt;p&gt;The actual "burraco" — the meld that gives the game its name — is a run of at least 7 cards: &lt;strong&gt;clean&lt;/strong&gt; if made of real cards only, &lt;strong&gt;dirty&lt;/strong&gt; if it contains one or more wilds. It's also the condition required to close a hand: without at least one burraco on the table, melding your entire hand isn't enough to win.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pot: a rule that depends on history, not the present
&lt;/h2&gt;

&lt;p&gt;The pot is a stack of 11 cards that can be taken whole instead of drawing a single card from the deck. To open it, though, a player must have melded at least one set or one &lt;strong&gt;clean&lt;/strong&gt; run — no wilds. This is the rule that forced me to rethink the game state: you can't just look at what's on the table right now, because once the pot is open it stays open even if only dirty melds follow. You need a persistent flag per player — "has already opened" — set once, on the first clean meld, and never recalculated from scratch.&lt;/p&gt;

&lt;p&gt;Whoever takes the pot adds all 11 cards to their hand in one move: a huge advantage in terms of possible combinations, but also eleven extra cards to get rid of before closing. Deciding when it's worth taking is one of the most important calls even for a human opponent, which makes it a good lever for tuning how "aggressive" the AI plays.&lt;/p&gt;

&lt;h2&gt;
  
  
  A three-tier AI, no neural networks
&lt;/h2&gt;

&lt;p&gt;No machine learning: the opponent runs on different heuristics per difficulty, applied at three points in the turn — whether to take the pot, searching for possible melds, and choosing which card to discard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Easy.&lt;/strong&gt; Takes the pot only 12% of the times it could, looks exclusively for sets already sitting in hand (no run search at all), attempts a single meld per turn and then stops, and discards a random card — even a wild if it comes up, which is objectively a weak move but realistic for a beginner tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Medium.&lt;/strong&gt; Takes the pot 30% of the time and switches to a &lt;strong&gt;greedy&lt;/strong&gt; loop: looks for sets, then runs, then tries adding cards to melds already on the table, repeating until it finds something to play or hits a safety iteration cap. For the discard, it picks the highest-value non-wild card, to shed the risk of being stuck with heavy cards at the end of the hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard.&lt;/strong&gt; Takes the pot 65% of the useful times, runs the same greedy loop as medium but also reuses leftover wilds to extend its own runs toward a burraco (7 cards), and crucially chooses its discard by reading the table: it computes which suits and ranks the human opponent is actually using in their melds, and avoids discarding cards they'd need, keeping wilds as a last resort.&lt;/p&gt;

&lt;p&gt;The result is an opponent that, on the same codebase, behaves noticeably differently just by changing a handful of numeric thresholds and adding a couple of targeted heuristics — without the complexity of a real search engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing a hand, scoring, and what gets saved
&lt;/h2&gt;

&lt;p&gt;A hand ends when a player melds their last card with no discard left, having already made at least one burraco — or in a stalemate if the deck runs out before anyone closes. Scoring sums the value of each player's melded cards and subtracts whatever's left in hand, with dedicated bonuses for clean and dirty burracos. Match history and total score are saved in &lt;code&gt;localStorage&lt;/code&gt;: no account, but also no syncing across devices — a choice consistent with the "open and play" goal, worth revisiting only if real multiplayer with a backend becomes necessary.&lt;/p&gt;

&lt;p&gt;I'd already used the same installable, zero-dependency PWA approach for &lt;a href="https://roversia.it/blog-17-pwa-gioco-breakout-canvas-audio-powerup.html" rel="noopener noreferrer"&gt;Neon Breakout&lt;/a&gt;: the nature of the problem changes — real-time physics and collisions on canvas there, discrete rule validation and heuristic AI here — but the underlying philosophy stays the same: a self-contained HTML file, installable as an app, with no build step.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do you validate a run of cards with wildcards in JavaScript?&lt;/strong&gt;&lt;br&gt;
Separate real cards from wilds, compute the span between the lowest and highest rank of the same suit, and check that internal gaps are covered by the available wilds. Leftover wilds can then extend the run past either end, as long as it stays within the ace-to-king range.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you code an AI opponent for a card game like burraco?&lt;/strong&gt;&lt;br&gt;
No neural networks or exhaustive search needed: different heuristics per difficulty tier, applied to pot-taking probability, a greedy search for possible melds, and discard choice based on what the opponent is using.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you play free online burraco without signing up?&lt;/strong&gt;&lt;br&gt;
Yes: open the page and play right away against the computer, no account needed. Match history and score are saved locally in the browser via localStorage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does the pot work in burraco, and why is it the trickiest rule to code?&lt;/strong&gt;&lt;br&gt;
It opens when a player melds their first set or first clean run, with no wilds. It's tricky because it depends on that player's meld history, not the current table state: you need a persistent flag set only once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should you write a card game in vanilla JavaScript or use a framework?&lt;/strong&gt;&lt;br&gt;
For a card game with a classic HTML/CSS interface and validation logic, a framework adds complexity with no real benefit. It only starts paying off with real-time multiplayer or complex shared state.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;You can play the finished game at &lt;a href="https://roversia.it/giochi/burraco" rel="noopener noreferrer"&gt;roversia.it/giochi/burraco&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>gamedev</category>
      <category>webdev</category>
      <category>pwa</category>
    </item>
    <item>
      <title>A Black Box Drawn Over a PDF Isn't Redaction — Here's How I Fixed It Client-Side</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Wed, 15 Jul 2026 13:11:10 +0000</pubDate>
      <link>https://dev.to/androve2k/a-black-box-drawn-over-a-pdf-isnt-redaction-heres-how-i-fixed-it-client-side-10hd</link>
      <guid>https://dev.to/androve2k/a-black-box-drawn-over-a-pdf-isnt-redaction-heres-how-i-fixed-it-client-side-10hd</guid>
      <description>&lt;p&gt;I wanted a complete PDF editor running entirely in the browser — page reordering, annotations, highlights, a drawn signature, watermark — on the same zero-upload principle as the rest of my site's tools. The interesting technical problem wasn't the interface. It was redaction.&lt;/p&gt;

&lt;p&gt;A black box drawn over text in a PDF covers it visually, but the text stays in the document underneath, selectable and copyable. I wanted to fix that properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive approach (and why it's wrong)
&lt;/h2&gt;

&lt;p&gt;Most "redact PDF" tools out there just draw a shape over the text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Just draws a black rectangle over existing content&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pdfDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pageIndex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawRectangle&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// The original text is still in the page's content stream:&lt;/span&gt;
&lt;span class="c1"&gt;// selectable, copyable, extractable with any PDF parser&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open the resulting PDF in any reader, select "all text on the page" (or just copy the black area itself), and the "hidden" content reappears in the clipboard. At the data level, nothing was removed — the PDF just gained a colored rectangle on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: rasterize, but only where it matters
&lt;/h2&gt;

&lt;p&gt;The only way to truly remove content while staying fully client-side, with no server-side content-stream rewriting engine, is to convert the page into an image after applying the blackout. A bitmap has no "text underneath" to extract — there's no text left, just pixels.&lt;/p&gt;

&lt;p&gt;The obvious trade-off: rasterizing bloats file size and kills text selectability. So the rule is to rasterize &lt;strong&gt;only pages that actually contain a redaction&lt;/strong&gt;, and leave every other page exactly as it is in the original.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified export logic&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasRedaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;redactionsByPage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hasRedaction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ✅ No redaction: page copied intact, stays vector&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;copied&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;outputDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copyPages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceDoc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nx"&gt;outputDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;copied&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 🔥 Page with redactions: rasterized at high resolution&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;renderPageToCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pdfjsDoc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;burnRedactionsIntoCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;redactionsByPage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nf"&gt;burnAnnotationsIntoCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;annotationsByPage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jpg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;outputDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedJpg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toJPEG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;outputDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nx"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jpg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here. First, rasterization happens at a higher scale (3x) than on-screen display, otherwise the "burned" pages look noticeably blurrier than the vector pages that stayed in the document. Second, on rasterized pages, non-redaction annotations (text, highlights, shapes, signature, watermark) get burned into the same bitmap too — mixing rendering types on the same page causes inconsistencies across different PDF readers.&lt;/p&gt;

&lt;p&gt;The result: a 20-page document with one redaction on page 12 produces a PDF where 19 pages stay light, vector, and text-selectable, and only page 12 is an image — with nothing extractable under the blackout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: canvas + coordinated overlay
&lt;/h2&gt;

&lt;p&gt;The rendering pattern reuses something I'd already built for a PDF form-filler tool: each page is a PDF.js rendering &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; plus an HTML overlay of the same size, where annotations live as elements positioned as a &lt;strong&gt;percentage of the current viewport&lt;/strong&gt;, not absolute pixels. That keeps them aligned with the underlying text at any zoom level — pixel-anchored annotations would drift on every resize.&lt;/p&gt;

&lt;p&gt;Rendering is also guarded by a per-page render token: whenever scale or content changes, a new token is generated, and an in-flight render that's no longer the latest one self-cancels instead of painting a stale frame over a fresh one. Without this, rapidly resizing the window during a heavy render produces visually broken overlapping frames — a classic async-canvas race condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;"Redaction" carries an implicit irreversibility requirement that a graphic overlay alone doesn't satisfy.&lt;/li&gt;
&lt;li&gt;Rasterize selectively, not the whole document — treating an entire PDF as "less trustworthy" because of one sensitive page is a bad trade-off.&lt;/li&gt;
&lt;li&gt;Anchor annotations to the viewport, not to screen pixels.&lt;/li&gt;
&lt;li&gt;A per-page render token avoids race conditions on async canvases during rapid resize/zoom.&lt;/li&gt;
&lt;li&gt;Rasterize at a higher scale than the on-screen display scale, or burned pages look noticeably worse than the vector ones next to them.&lt;/li&gt;
&lt;li&gt;Code written for one tool pays off on the next one — the rendering/overlay engine and drawn-signature logic came from an earlier PDF form-filler tool and saved a lot of time here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything runs client-side: no upload, no server processing, the file never leaves the browser.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>pdf</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A plaintext Firebase password authenticated anyone who visited the site — here's how I fixed it without disconnecting anyone</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Fri, 10 Jul 2026 03:15:08 +0000</pubDate>
      <link>https://dev.to/androve2k/a-plaintext-firebase-password-authenticated-anyone-who-visited-the-site-heres-how-i-fixed-it-38je</link>
      <guid>https://dev.to/androve2k/a-plaintext-firebase-password-authenticated-anyone-who-visited-the-site-heres-how-i-fixed-it-38je</guid>
      <description>&lt;p&gt;While doing a routine hardening pass on an internal Firebase panel — codename &lt;strong&gt;PanelControl&lt;/strong&gt;, a management tool used daily by multiple operators with different roles — what was supposed to be "let's add a few Telegram alerts for suspicious activity" turned into discovering that the app's entire login system was just a UI filter.&lt;/p&gt;

&lt;p&gt;Anyone who opened the site already had, automatically, a Firebase identity with full read/write access to the database. Here's what happened, and how it got fixed in 5 phases without ever locking the team out mid-shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;PanelControl is a vanilla-JS internal panel backed by Firebase Realtime Database + Firestore. Operators log in with email/password, checked client-side against a database node, with a lockout after failed attempts. Nothing unusual so far.&lt;/p&gt;

&lt;p&gt;The original ask was narrow: add Telegram notifications for a handful of suspicious events — brute-force attempts, a never-before-seen device for an operator, an unauthorized attempt to reach the Admin section, DevTools opened during use. Pure alerting work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: the login button that always unlocks
&lt;/h2&gt;

&lt;p&gt;Before writing any alerting logic, a review of the existing Admin-area password check turned up this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ The "|| true" makes the whole condition always truthy&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkAdminPwd&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;unlockAdmin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// runs regardless of what's typed, or nothing at all&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A debug leftover that made it to production. Anyone who landed on the Admin password overlay got in by clicking "Log in" — password or not. Fixed by actually wiring the real permission check, plus a server-side-verified fallback in case the function were ever called directly from the console.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real discovery: a shared, hardcoded Firebase credential
&lt;/h2&gt;

&lt;p&gt;Looking at the Realtime Database Rules ahead of the alerting work surfaced something much bigger. The Rules restricted read/write to a single fixed &lt;code&gt;auth.uid&lt;/code&gt; — reasonable, until you check who actually gets that &lt;code&gt;uid&lt;/code&gt;. This ran unconditionally, for every visitor, before the login screen even appeared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Plaintext Firebase credentials, executed for EVERY visitor&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;_fbEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[shared-account]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[domain]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;_fbPass&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[REDACTED]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;firebase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;signInWithEmailAndPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_fbEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_fbPass&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app's real login form — personal email, personal password, permission checks — protected nothing at the database level. It was a UI filter sitting on top of a database that was already wide open to anyone who simply loaded the page. No need to even read the source to get the credentials; the app authenticated itself with them automatically.&lt;/p&gt;

&lt;p&gt;This isn't a one-line patch. Credentials shipped in a public JS bundle can never be truly secret, and with one Firebase identity shared by every operator, the Rules have no way to tell an authorized operator apart from anyone who just copied those two lines into an HTTP client.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The real security perimeter of a Firebase app is its Rules, not the login form the browser shows.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The fix: a serverless login function + Custom Tokens with role claims
&lt;/h2&gt;

&lt;p&gt;The architecture moves identity verification server-side, where the Firebase service account is never exposed to the client:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The operator submits email + password to a Netlify Function&lt;/li&gt;
&lt;li&gt;The function verifies credentials against the operators node (PBKDF2 via Web Crypto API) using the service account — never in the client bundle&lt;/li&gt;
&lt;li&gt;If valid, it computes the role and signs a &lt;strong&gt;Custom Token&lt;/strong&gt; with the operator's real identity as a claim&lt;/li&gt;
&lt;li&gt;The client calls &lt;code&gt;signInWithCustomToken()&lt;/code&gt; instead of the old hardcoded login&lt;/li&gt;
&lt;li&gt;Database Rules check &lt;code&gt;auth.token.operatore&lt;/code&gt; / &lt;code&gt;auth.token.admin&lt;/code&gt; instead of a single fixed &lt;code&gt;uid&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Netlify Function — server-side only, never shipped to the client&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Server-side rate limit: 5 attempts, then 60s lockout per email&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getAttempts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;locked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;423&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Verify against the operators node (service account, never client-side)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;op&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;findOperatorByEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;op&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passwordHash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;registerFailedAttempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Role claims computed server-side, unforgeable by the client&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;operatore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chiave&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;mintCustomToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`op_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chiave&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;operatore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chiave&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key point: &lt;code&gt;admin&lt;/code&gt; and &lt;code&gt;operatore&lt;/code&gt; are decided exclusively server-side and signed into a JWT with the service account's private key. The client can't "become admin" by flipping an in-memory variable — the claim is checked by the database Rules on every single read/write, not just when the session opens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling it out in 5 phases
&lt;/h2&gt;

&lt;p&gt;Swapping the Firebase identity of an app used daily by a whole team isn't a one-shot change:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Isolated function&lt;/strong&gt; — deployed but not called from the client yet. Zero risk, testable directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client with fallback&lt;/strong&gt; — login tries the new function first, falls back to the old method if it doesn't respond.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification on real accounts&lt;/strong&gt; — admin and non-admin operators from different departments, console open hunting for permission errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Removing the hardcoded credentials&lt;/strong&gt; — auto-login gone, the app waits for &lt;code&gt;onAuthStateChanged()&lt;/code&gt; instead, with session persistence synced to the "Remember me" checkbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deny-by-default Rules&lt;/strong&gt; — only once the Service Worker has propagated the new client to the whole team.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Snag #1: Rules need touching mid-migration, not at the end
&lt;/h2&gt;

&lt;p&gt;The original plan left the Rules untouched until the last phase. As soon as the client started authenticating with the Custom Token in phase 2, &lt;code&gt;permission_denied&lt;/code&gt; errors appeared on realtime chat listeners still open from before login.&lt;/p&gt;

&lt;p&gt;Reason: changing Firebase identity at runtime immediately invalidates the permissions of anything already open under the old identity. The old Rules only authorized the shared &lt;code&gt;uid&lt;/code&gt; — the new, perfectly valid Custom Token meant nothing to them.&lt;/p&gt;

&lt;p&gt;Fix: an additive &lt;code&gt;OR&lt;/code&gt;, safe to publish mid-workday because Firebase Rules aren't sessions — every read/write is re-evaluated in real time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;".read"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"auth != null &amp;amp;&amp;amp; (auth.uid === '[SHARED_UID]' || auth.token.operatore != null)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;".write"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth != null &amp;amp;&amp;amp; (auth.uid === '[SHARED_UID]' || auth.token.operatore != null)"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Snag #2: deny-by-default broke the whole app for non-admins
&lt;/h2&gt;

&lt;p&gt;Once the team was fully on the new client, the Rules moved to a genuinely restrictive model: root &lt;code&gt;false&lt;/code&gt; by default, granular per-role permissions per node.&lt;/p&gt;

&lt;p&gt;First test with a non-admin account: the app opened completely empty. No visible console errors. The cause: one node had been classified "admin read-only," but it was also one of the nodes the app reads in full &lt;strong&gt;before showing any screen at all&lt;/strong&gt;, regardless of role. Restricting it left every non-admin operator hanging on a read that would never resolve — no visible error, because that specific listener had no explicit error callback.&lt;/p&gt;

&lt;p&gt;A second node, holding operator profile data, exposed a structural limit of Firebase Rules: &lt;strong&gt;they don't partially filter a node.&lt;/strong&gt; If the client reads it all at once, permission is granted or denied for the entire content — you can't say "department and email yes, password hash no" within the same node without first splitting the data. Not something to improvise mid-debug, so that node was temporarily left open to all operators (no regression — it was already fully exposed under the old shared identity), with the split planned as follow-up work.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Before restricting a node to a specific role, always check whether it's part of the app's bootstrap path for &lt;em&gt;every&lt;/em&gt; user — not just the role that's "supposed" to see it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Cleaning up: delete the account, don't just rotate the password
&lt;/h2&gt;

&lt;p&gt;One loose end: the original shared Firebase account. Rotating its password isn't enough — nothing uses it anymore, and Firestore's rules (separate from Realtime Database) only checked &lt;code&gt;request.auth != null&lt;/code&gt;, without checking &lt;em&gt;which&lt;/em&gt; user. Anyone who still had the old credential could still authenticate and touch Firestore data. Cleanest fix: delete the account from Firebase Authentication entirely. An account nothing depends on doesn't need a stronger password — it needs to not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A UI-level login isn't security if the database Rules don't mirror it — worth verifying explicitly even when the login "looks" correct.&lt;/li&gt;
&lt;li&gt;Changing Firebase identity at runtime invalidates permissions on everything already open — update Rules additively as you go, not at the end.&lt;/li&gt;
&lt;li&gt;Firebase Rules can't partially filter a node — split sensitive data out before writing granular permissions.&lt;/li&gt;
&lt;li&gt;Before restricting a node, check if it's on the app's bootstrap path for everyone.&lt;/li&gt;
&lt;li&gt;A real rate limit lives server-side — a client-only counter resets on page reload.&lt;/li&gt;
&lt;li&gt;An unused account should be deleted, not renewed — if a second system doesn't check the same claim, an old credential is still an open door.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Full bilingual write-up (with the RTDB Rules diffs, the security-alert Telegram integration, and the complete phase-by-phase checklist) on my &lt;a href="https://roversia.it/blog-18-firebase-custom-token-migrazione-rtdb-rules.html" rel="noopener noreferrer"&gt;blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>security</category>
      <category>netlify</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a Breakout PWA from scratch: canvas, synthesized audio, and power-ups in one HTML file</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Tue, 07 Jul 2026 07:37:38 +0000</pubDate>
      <link>https://dev.to/androve2k/building-a-breakout-pwa-from-scratch-canvas-synthesized-audio-and-power-ups-in-one-html-file-1jkh</link>
      <guid>https://dev.to/androve2k/building-a-breakout-pwa-from-scratch-canvas-synthesized-audio-and-power-ups-in-one-html-file-1jkh</guid>
      <description>&lt;p&gt;I wanted a small arcade game, installable as an app, with no dependencies and no build step. Here's how a complete Breakout came together — from a minimal skeleton to 15 hand-drawn levels, sound effects generated with the Web Audio API without downloading a single audio file, particles, screen shake, and power-ups balanced so the playfield doesn't get overcrowded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack first, game second
&lt;/h2&gt;

&lt;p&gt;The first question wasn't "which game" but "with what". For a simple or medium 2D game the practical options are few:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native canvas + vanilla JavaScript&lt;/strong&gt; — maximum control, zero dependencies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phaser.js&lt;/strong&gt; — a full framework with physics included, useful if you want to save time on collisions and sprites&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain HTML/CSS&lt;/strong&gt; — for games with complex UI (cards, puzzles, quizzes), often with no game engine needed at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Since the rest of the site is already built as single-file HTML pages with no build step, the natural choice was to stay consistent: native canvas, one self-contained file, easy to understand and deploy.&lt;/p&gt;

&lt;p&gt;The minimum requirements to make it actually work as an &lt;strong&gt;installable PWA&lt;/strong&gt; are three:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;code&gt;manifest.json&lt;/code&gt; with a name, icons at least 192px and 512px, and &lt;code&gt;display: "standalone"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;service worker&lt;/strong&gt; for offline caching and installability&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS&lt;/strong&gt;, mandatory but already provided free by many hosting providers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Progress saving, at this stage, is deliberately simple: &lt;code&gt;localStorage&lt;/code&gt;, no backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Breakout
&lt;/h2&gt;

&lt;p&gt;Among the options considered — a grid puzzle, a themed memory game, a timed quiz, a Wordle-like, a light idle-management game — Breakout won on the ratio between development time and immediate payoff: simple collision logic, native canvas with no complex sprites, and a genre that lends itself well to becoming an installable arcade app.&lt;/p&gt;

&lt;p&gt;The initial choices were deliberately minimal to get started quickly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Iteration&lt;/th&gt;
&lt;th&gt;What it adds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 — Skeleton&lt;/td&gt;
&lt;td&gt;Canvas, paddle, ball, random bricks, installable PWA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 — Levels&lt;/td&gt;
&lt;td&gt;10 then 15 fixed levels, multi-hit and indestructible bricks, level selector&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 — Visuals and audio&lt;/td&gt;
&lt;td&gt;Trail, particles, synthwave background, sounds via Web Audio API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 — Game feel&lt;/td&gt;
&lt;td&gt;Screen shake, 4 power-ups, drop-probability balancing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  From random levels to 15 hand-drawn ones
&lt;/h2&gt;

&lt;p&gt;The first version generated bricks with a semi-random grid: functional, but with no personality. The next step was replacing it with hand-drawn patterns — pyramids, checkerboards, diamonds, corridors, a "fortress" — each with a deliberate difficulty curve.&lt;/p&gt;

&lt;p&gt;At that point two kinds of special brick came in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;multi-hit&lt;/strong&gt;: need two hits, change color after the first and award more points&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;indestructible&lt;/strong&gt;: bounce the ball but never break and don't count toward level completion&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 An easy detail to forget: once you add indestructible bricks, every place in the code that checks "how many bricks are left to finish the level" needs to explicitly exclude them, or the level becomes impossible to complete.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With 10 fixed levels came the rest of the progression too: high score and highest level reached saved together in &lt;code&gt;localStorage&lt;/code&gt;, and a level selector that unlocks only levels already seen at least once. Later the progression was extended to 15 levels, with denser patterns toward the end, up to a final level that's almost entirely indestructible except for one opening row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neon visuals and sound without a single audio file
&lt;/h2&gt;

&lt;p&gt;The base version worked but looked visually bare. Next came a glowing trail behind the ball, colored particles when a brick explodes, a synthwave-style grid-and-scanline background, a pulsing glow on the paddle, and a flash on contact with the ball.&lt;/p&gt;

&lt;p&gt;For audio, the goal was zero files to download: the &lt;strong&gt;Web Audio API&lt;/strong&gt; lets you generate oscillators directly in JavaScript, modulating frequency and volume over time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;beep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;square&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;volume&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;audioCtx&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;muted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;osc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOscillator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;volume&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exponentialRampToValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A different sound for a wall bounce, a paddle bounce, a normal brick, a multi-hit brick, and an indestructible one, plus short melodies for losing a life, game over, and level complete.&lt;/p&gt;

&lt;p&gt;One non-negotiable technical detail: browsers require a user interaction to start the audio context, so the first sound only plays on the first tap or click — it should be handled as expected behavior, not a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screen shake and power-ups: from "it works" to "it feels alive"
&lt;/h2&gt;

&lt;p&gt;Screen shake, when you lose a life, is simpler than it sounds: apply a random, decreasing &lt;code&gt;translate()&lt;/code&gt; to the canvas context for a few frames before drawing the scene, without touching the real position of the ball, paddle, or bricks.&lt;/p&gt;

&lt;p&gt;For power-ups (wide paddle, slow ball, 3-ball multi-ball, extra life), the tricky part wasn't the idea but restructuring the update logic: the main ball and any extra multi-ball balls had to share the same behavior instead of duplicating code. The more robust fix was extracting a reusable &lt;code&gt;updateSingleBall()&lt;/code&gt; function, called in a loop over every ball on screen.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ The first attempt at "slow ball" scaled dx/dy every frame with a multiplicative factor, risking divide-by-zero when restoring speed. The more robust version applies the scaling once, on activation and deactivation of the effect, not every frame.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Balancing drop probabilities
&lt;/h2&gt;

&lt;p&gt;The first power-up version used a 28% total drop chance per normal brick destroyed — with hundreds of bricks per playthrough, the field got crowded far too fast.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Power-up&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🟢 Wide paddle&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔵 Slow ball&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🟡 Multi-ball&lt;/td&gt;
&lt;td&gt;7%&lt;/td&gt;
&lt;td&gt;3.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔴 Extra life&lt;/td&gt;
&lt;td&gt;3%&lt;/td&gt;
&lt;td&gt;1.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The general principle: minor helpers can stay relatively frequent because their impact is limited and temporary, while strong ones need to stay rare — otherwise the game loses the tension that makes it interesting. Power-ups only drop from normal bricks, never from multi-hit or indestructible ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;p&gt;The game is now a fully installable PWA: 15 fixed levels with multi-hit and indestructible bricks, a level selector, visual and audio effects generated without external assets, screen shake, and 4 balanced power-ups. The service worker caches all assets for offline use, and the cache version gets bumped on every deploy to avoid mismatches between the served and cached versions.&lt;/p&gt;

&lt;p&gt;Openly postponed for now: more advanced power-ups and syncing scores across devices via a backend, instead of the current &lt;code&gt;localStorage&lt;/code&gt;-only approach.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Original article with the bilingual IT/EN version, FAQ, and a live demo of the game: &lt;a href="https://roversia.it/blog-17-pwa-gioco-breakout-canvas-audio-powerup.html" rel="noopener noreferrer"&gt;roversia.it&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>pwa</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
