Shipping an LLM chat widget with 481 bytes of CMS footprint - Wordpress
DEV Community

Shipping an LLM chat widget with 481 bytes of CMS footprint - Wordpress

A chat widget where the CMS holds a loader and nothing else - plus the four failure modes that cost me the most time: buffered SSE, base64 truncation that passes a syntax check, render-time escaping, and a balanced-but-wrong tag tree. Canonical_url https://dhseadev.online/2026/08/06/ai-answers-desk-val-town-groq/ I maintain a small site where I write up the internal tooling I build - process-serving automation, Chrome extensions, desktop utilities. It had a static answers page. The obvious next step was letting people ask the things that page doesn't cover. Two constraints shaped the whole design: - No server for me to maintain. - The API key never reaches a browser, and never enters the CMS database. What follows is the architecture I landed on, and - more usefully - the four things that actually cost me time. Three of those four are general enough to bite you on a completely different stack. The shape The CMS side is 481 bytes. One block, and all it does is: window.DHSEA_ASK_ENDPOINT = "https://…"; // then inject That's it. No plugin. No key in the database. No logic in the page. Everything else - the ~19KB widget, the styles, the chat logic, the proxy - lives in a single serverless file with three routes: | Route | Job | |---|---| GET /health | Diagnostics. Written so it can never 500. | GET /widget.js | Serves the widget itself. | POST / | Chat proxy to the model API. | The API key is an env var on the serverless platform. Because the widget is served from the same function rather than pasted into the CMS, shipping a widget change is one external deploy and I never touch the site. /health deserves a note. A health check that can throw is worse than no health check, because it fails in exactly the situation you're using it to diagnose. Mine returns {ok, keyConfigured, store, storeError, fatal, day, dayReq, dayTokens, …} - errors become fields, not exceptions. Budget, because it's my card Public endpoint plus paid inference is a bad combination unless you bound it up front: - per-IP: 4/min, 30/day - global: 12/min, 1500/day - hard ceiling: 300k tokens/day Counters live in the platform's SQLite so they survive cold starts - in-memory counters on a serverless host reset when the instance recycles, which means no rate limit at all under exactly the traffic pattern you care about. On top: an origin allowlist. My domain gets 200. A lookalike domain gets 403. A request with no Origin header gets 403. Test all three; the lookalike case is the one naive startsWith checks fail. Four things that cost me the most time 1. Your platform may buffer SSE The model API streams tokens properly. My serverless host buffers, so what reached the browser was roughly two chunks - not a stream. Total round trip is under a second, so it reads fine. But I'd already started building a token-by-token typewriter UI that was never going to work on that host. Verify streaming end to end on your actual deployment target, not on the vendor's docs page. The vendor streams. That tells you nothing about what your host does with it. 2. A truncated base64 payload passes a syntax check The widget is base64-embedded into the serverless file by a build script. If a copy step truncates the blob: - the file still parses - node --check is happy - atob fails at runtime - the panel renders blank A syntax check attests the file, never the payload. So: never hand-copy the payload (a build script does it file-to-file), and verify the deploy against the live artifact: curl -s https://…/widget.js | shasum -a 256 …compared against the same hash computed on the local build. Same byte length is not the same bytes. Compare hashes. Mine matched at 19,260 chars - that match is the evidence the deploy worked, not the fact that the paste didn't throw. 3. Verify the served bytes, not the saved content My CMS applies escaping at render time. So the stored content was byte-clean while the live page was dead and the console was empty. Reading back what you just saved proves the write succeeded. It proves nothing about what the visitor receives. Fetch the rendered output and assert on that. This generalizes past CMSs: anywhere a transform sits between your write and the user - a bundler, a minifier, a template engine, a CDN - the artifact you validated is not the artifact they get. 4. Balanced tags are not a correct tree This is my favorite, because every mechanical check I had passed it. A page with opening and closing div counts matching exactly - 14 and 14. And six sections nested inside a seventh, because one closing tag had been deferred to the end of the document. Balance counting cannot catch this. Absence and misplacement are different bugs, and counting only detects absence. Parse it instead: const doc = new DOMParser().parseFromString(html, "text/html"); doc.querySelectorAll(".qa .qa").length; // 6 β†’ nested. Should be 0. doc.querySelectorAll(".in > .qa").length; // should equal total .qa That assertion is the one that catches it, and it's the one I now run. The through-line Every one of these four is the same mistake wearing a different hat: I checked the thing I could reach instead of the thing the user gets. The saved content instead of the served page. The file's syntax instead of the payload's integrity. The vendor's streaming behavior instead of my host's. The tag count instead of the tree. Each check passed. Each one attested something adjacent to what I actually needed to know. Whatever sits between your code and your user - a renderer, a host, an encoder, a build step - is the boundary your verification has to cross, or it isn't verification. The result is live at dhseadev.online/ask, sitting next to the static answers it was built to extend. If you break it, I'd like to hear how. Top comments (0)

Comments

No comments yet. Start the discussion.