Projects
Three things I built, with the problem behind each one, the decision I made, and what I would do differently today.
Bananablet
MV3 · Cloudflare Workers · R2 · Supabase · VPS
Bananablet is a browser extension that adds functionality to an online game client. I started it because the game has no API, no SDK and no official extension point: anyone who wants to build on top of it has to intercept the binary WebSocket the client uses to talk to the server. It has been live since November 2025, with more than 4,000 installs and 1.9k people using it monthly.
It is my own project, built alone, and the only one of the three that did not come from a client request.
Technologies
- Manifest V3 extension, injected into the page context to reach the game runtime
- Cloudflare Workers for the edge API
- Cloudflare R2 for assets and chat audio
- Supabase, with Postgres, for state that has to persist
- A Node VPS for the persistent WebSocket connection
- Language models for automatic translation and content moderation
- A test suite of 197 files
What was hard
A game update started drawing packet identifiers per connection, deriving each one from a hash over a challenge the server sends at bootstrap. That broke 115 call sites at once. I could have remapped the values on every release, but I chose to replicate the client's own derivation function: I refactored 124 call sites and now cover roughly 147 identifiers with no maintenance at all. The price is being coupled to an internal detail of the game, and if they change the algorithm I work it out again from scratch.
Measuring the Worker, I found 707,000 requests in 24 hours, and almost all of it came from the cheapest route I had: every client asking every few seconds who was online. I migrated presence to a persistent WebSocket connection and traffic dropped roughly 95%.
I should have instrumented sooner. I only found this during a one-off investigation, a week after the system was already live, and with per-route telemetry from the start I would have seen it on my own.
See it on the Chrome Web Store
Virtual Try-On — Qualidade Visual
Node · TensorFlow.js · Gemini · Docker · Traefik
Qualidade Visual is an optical retailer here in Juazeiro do Norte, and what they wanted was easy to describe and annoying to solve: the customer seeing the glasses on their own face before buying, right on the product page, with nothing to install and no detour out of the purchase flow.
The problem is that the Gemini call generating the image is expensive, slow, and sometimes comes back with the wrong face or a crooked frame. The obvious path would be generate, look, retry when it comes back bad, except the wait then multiplies by the number of attempts.
Technologies
- Node with Express, split into two services behind a reverse proxy
- TensorFlow.js for facial validation on the server
- Google Gemini's image generation API to compose the frames onto the photo
- Managed Postgres for the support conversation state
- Docker and Traefik for the VPS deployment
- A custom WordPress plugin to embed the widget in the store
- In-browser facial landmark detection, before the upload
What was hard
Instead of retrying in sequence, I fire several generations at once and validate them all in parallel, comparing each candidate against the original photo with a facial embedding. The shortest distance wins. I pay for all of them every time, including when the first was already good, but the user's wait stops growing with the number of attempts.
Browsing the catalog inside the widget meant querying the store API on every interaction, so I built three in-memory cache layers plus on-disk image caching. Measured in production, the set hits 80.5% over 9,994 lookups.
In those same measurements something the design had not anticipated showed up: one of the three layers is never reached, because the search layer always answers first. Two cover the same ground. I found that by measuring rather than by reading the code, and it is the kind of thing that only surfaces with a number in front of you.
See the Qualidade Visual store
Real estate feeds — Alta Performance
PHP · WordPress · MySQL · XML
Alta Performance is a real estate agency that already had its inventory inside WordPress and needed to publish it to Chaves na Mão, Imovelweb and VRSync, the OLX group feed that supplies Vivareal and ZAP. Each one demands an XML file in its own format.
Generating XML is the easy part. The hard part is that all three describe the same property in mutually incompatible ways, and a property valid for one comes back rejected from another with a generic error that never says which field is wrong.
Technologies
- PHP inside WordPress, no framework, across 28 classes
- MySQL, with a dedicated table for per-portal validation logs
- Streaming XML generation, to avoid holding the whole document in memory
- A local dataset of 1,481 cities, to resolve locality without an external call
- WordPress cron to regenerate the feeds
- Docker for the development environment
What was hard
The three speak different vocabularies for the same thing. Chaves na Mão uses Portuguese terms and demands 48 tags written always, even when empty. Imovelweb, whose API is OpenNavent, sends everything in CDATA, with the date as a millisecond epoch and the price operation in Spanish. VRSync is the only one with a namespace and an XSD, uses English vocabulary, keeps its semantics in attributes rather than elements, and wants the postal code digits only, the opposite of Imovelweb. An abstract class handles what they share and each portal implements only its own document assembly, with a required-field validator per portal. That way the log says exactly what was missing in each one, and "the portal rejected it" becomes an actionable list.
Importing partner listings brought 20 to 30 photos per property, which blows past any execution limit on shared WordPress hosting. The common way out is raising the limits, and there is not a single call like that in the plugin: the feed is downloaded once and sliced into windows of 50 items, and the browser chains the requests, so every PHP process stays short.
Listings were reaching the portal with the description collapsed into a wall of text, even though it was correct in WordPress. I captured the real feed going out and reproduced the normalization function outside WordPress, which turned "the portal displays it wrong" into a thirty-line test. Had I written fixture tests on the generators from the start, I would have caught that one and one more before the portal did.