AcornReply
← All posts

A chat widget that won't touch your site

11 min read
  • #frontend
  • #sse
  • #iframe

You paste one <script> tag into your site and a chat bubble appears in the corner. A visitor clicks it, types a question, and a reply streams back in real time. That's the demo. The engineering problem is everything the demo hides, and almost all of it comes from a single constraint: the widget runs on someone else's page.

That page has its own CSS, its own JavaScript, its own z-index wars, its own analytics, maybe its own React tree re-mounting things underneath you. Your widget has to render a live, authenticated, streaming chat UI in that environment without breaking the host, without the host breaking it, and without pulling in a framework the host never asked to download. This post is how Acorn Reply's chat widget does that — one iframe, an SSE stream delivered a slightly unusual way, and a loader that treats "don't touch the host" as the first requirement, not an afterthought.

The snippet that doesn't touch your site

The thing you paste is a small vanilla-JavaScript loader — no framework, no runtime dependencies, a single self-contained file built to a hard 14 KB size budget. Everything it needs is bundled in, including the SVG icons (inlined as strings, since it can't import from the app at runtime). If it ever grows past the budget, the build fails. A chat widget that ships a 100 KB bundle to every one of your visitors is a tax you're quietly levying on the host's page-load metrics, so the budget is a real constraint, not a vanity number.

The loader's prime directive is to leave no fingerprints on the host:

How the loader avoids polluting the host (pseudocode)
if window.__acornChat already exists: return   # idempotent — survive double-injection
 
root = create <div> { position: fixed; inset: 0; pointer-events: none }
append root to document.body
 
# every element is styled with INLINE styles only — never inject a stylesheet,
# so nothing can leak into the host's CSS and the host's CSS can't reach in
fab  = styled <button> { position: fixed; bottom: 24; right: 24; 44x44 }
card = styled <div>    { position: fixed; ...; visibility: hidden }
 
expose exactly one global: window.__acornChat  (+ a public AcornReply() fn)

Three habits do the heavy lifting here:

  • Inline styles, never a stylesheet. Injecting a <style> or class names invites collisions in both directions. Setting styles directly on each element means the widget's appearance can't bleed into the host and the host's selectors can't accidentally restyle the widget.
  • One global, and an idempotent guard. The loader claims a single namespaced global and bails immediately if it's already there. Hosts using a SPA that re-runs scripts on navigation — or React's double-invoked effects in development — won't get two widgets.
  • A real teardown. A destroy() removes the DOM it created, unbinds every document and window listener, disconnects its MutationObserver and media-query listeners, and deletes its globals. Single-page-app hosts that mount and unmount per route need the widget to actually leave when told.

The loader also ships a tiny stub queue, the same trick analytics SDKs use: AcornReply = AcornReply || function(){ (AcornReply.q ||= []).push(arguments) }. A host can call AcornReply('identify', …) or AcornReply('open') before the real script has finished loading; the calls queue up and replay in order once the dispatcher is ready. Async loading never drops an early call.

Visually it's a floating button, an unread badge, and a card that becomes a full-screen sheet below a 480px viewport. To stay on top of whatever the host is doing with stacking contexts, the widget sits near the 32-bit ceiling — z-index: 2147483000 — with the button, card, and resize grip a few above that. It's a blunt instrument, but "be on top" is genuinely the requirement.

Why an iframe

The loader draws a button and a card. The actual chat UI — transcript, composer, attachments — does not render in the host's document. It renders inside an <iframe> pointed at the app's own origin. That single decision is what makes the isolation real rather than aspirational.

Inline styles keep the loader's handful of elements from colliding with the host. But the chat UI is a full React application with its own design system, hundreds of class names, and its own fonts. There's no disciplined way to drop that into an arbitrary page without conflict. An iframe is a separate document with a separate origin: its DOM and CSS are physically inaccessible to the host, and the host's are inaccessible to it. Isolation stops being something you maintain and becomes something the browser enforces.

That enforcement is turned up deliberately. The iframe is sandboxed to the minimum set of capabilities chat needs, and denied every browser feature it doesn't:

The iframe's sandbox (pseudocode)
iframe.sandbox = "allow-forms allow-scripts allow-same-origin allow-popups"
iframe.allow   = ""     # empty Permissions-Policy: no mic, camera, geolocation, …
iframe.src     = "{APP_ORIGIN}/chat/{embedKey}?theme={scheme}"

Note what's absent: there's no allow-top-navigation, so the framed page can never navigate the host away — a common malicious-widget move — and the empty allow attribute switches off microphone, camera, geolocation, and the rest of the powerful features a chat box has no business requesting.

The one thing an iframe needs from the outside is permission to be framed. The app's default security headers forbid framing entirely (frame-ancestors 'none'), which is correct for every page except this one. The widget route explicitly opts back in with frame-ancestors *, so the chat page — and only the chat page — can be embedded anywhere, while the rest of the app stays un-framable.

Streaming without EventSource

Inside the iframe, replies need to arrive the instant a support agent (or the AI) responds. That's a server-to-client stream, and the textbook answer is Server-Sent Events via the browser's built-in EventSource. We use SSE — but not EventSource, and the reason is a one-line limitation with real consequences.

EventSource cannot send custom headers. The chat session is authenticated with a bearer token, which belongs in an Authorization header. EventSource gives you no way to set one; your only options are a query-string token (which lands in server logs and proxy caches) or a cookie (which drags in third-party-cookie partitioning headaches for a cross-origin iframe). Neither is good.

So the widget consumes SSE by hand, with fetch and a stream reader:

Consume SSE over fetch, with an auth header (pseudocode)
loop until stopped:
    res = fetch("/api/chat/stream", {
        headers: { authorization: "Bearer " + sessionToken },
        signal: abortSignal,
    })
    if res.status == 401 or res has no body: return       # unauthorized — give up
    reader = res.body.getReader()
    loop:
        chunk = await reader.read()
        if done: break                                    # server closed the stream
        if decode(chunk) contains "data: ":
            refetch_transcript()                          # <-- see next section
    sleep 1000 ms                                         # flat reconnect delay

The response is a normal text/event-stream; we just read it off a fetch body instead of handing the URL to EventSource. That recovers the ability to send an Authorization header — the whole reason for the detour — while keeping SSE's simplicity over the alternative. A WebSocket would also let us send auth, but chat only needs server-to-client push; the visitor's messages are ordinary POSTs. SSE over plain HTTP needs no upgrade handshake, sails through the same route handler and proxy as every other request, and maps one-to-one onto the database's LISTEN/NOTIFY, which is what actually wakes the stream when a new message lands.

The stream is a doorbell, not a pipe

Look again at what the client does when a chunk arrives: it checks whether the text contains data: and, if so, refetches the entire transcript from a separate authenticated endpoint. It never parses the event's contents. The SSE payload carries no message text at all — it's a bare "something changed" ping.

That sounds wasteful and is quietly one of the best decisions in the design:

Server side: notify, don't serialize (pseudocode)
on NOTIFY for channel "conversation:{id}":
    write "data: {}\n\n" to the stream        # just a nudge — no content
 
# client, on any nudge:
GET /api/chat/messages   ->   full transcript, replacing local state
  • Nothing sensitive can leak through the stream, because nothing of substance travels through it. The AI drafts a reply in parallel with the human; those drafts must never reach the customer. Since the stream only ever says "refetch," and the transcript endpoint simply never serializes drafts, there's no payload to accidentally over-share and no filtering logic to get wrong.
  • Every update is idempotent. A full-transcript replace can't drift out of sync the way incremental patch-and-append can. Miss a nudge, get a duplicate nudge, reconnect mid-stream — it doesn't matter; the next fetch is the whole truth. For a connection that's designed to drop and reconnect (next section), that robustness is the point.

The pattern is worth stating plainly: use the real-time channel to say that state changed, and a normal request to read it. You get real-time feel with request/response reliability, and the sensitive data never rides the firehose.

The sixty-second self-destruct

Long-lived HTTP connections have a natural enemy: intermediaries that kill idle streams. Proxies, load balancers, and dev servers tend to guillotine a connection somewhere around ninety seconds, and a stream cut by a proxy looks like an error at an unpredictable moment.

Rather than fight that, the server schedules its own ending:

Server-side stream lifecycle (pseudocode)
open stream:
    send ": keep-alive\n\n" every 15 s          # comment line; keeps it warm
    after 60 s: close the stream cleanly          # beat the ~90 s proxy timeout
on client disconnect OR server abort OR the 60 s cap:
    release the DB listener, clear timers, unbind — once, idempotently

The server proactively closes every stream at 60 seconds — comfortably under the proxy's ~90-second axe — and the client, seeing the body end, simply reconnects after its flat one-second delay. An infrastructure limitation becomes a deterministic release point we control, instead of a random failure we react to. The 15-second keep-alive comment line keeps the connection from being reaped as idle in between.

There's a happy side effect: because an open widget reconnects at least every minute, "is this visitor currently connected?" becomes reliably knowable — each reconnect touches the session. That signal is what lets the system decide whether to answer in the live widget or fall back to email.

Talking across the frame, carefully

The loader (on the host page) and the chat UI (in the iframe) still need to coordinate: open the card, report unread counts, mirror the host's dark-mode toggle. That's window.postMessage, and cross-frame messaging is a classic place to leak data or accept forged commands. The discipline is asymmetric, matching the trust in each direction:

postMessage discipline (pseudocode)
# host -> iframe: target the exact app origin; iframe verifies the sender
loader.postMessage({ type: "visible" },        APP_ORIGIN)
loader.postMessage({ type: "set-scheme", ... }, APP_ORIGIN)
iframe: on message -> if event.origin != APP_ORIGIN: drop
 
# iframe -> host: host origin is unknown (any site), so target "*" —
# but ONLY ever send non-secret data across the wildcard
iframe.parent.postMessage({ type: "unread", count: n }, "*")

Inbound to the iframe, messages are targeted at the known app origin and the receiver rejects anything from a different origin. Outbound to the host, the target origin is by definition unknown — the widget could be on any site — so it uses the wildcard *; the safeguard is that only non-secret data ever crosses that way. The unread count can go to any listener; the transcript never does. When you can't authenticate the recipient, you restrict the payload instead.

What the embed key can and can't do

The embedKey in the script URL is public by construction — it's in markup you paste onto a public page. It's a workspace identifier with a transcription-safe alphabet (no 0/O/1/l, so it survives being read aloud or copied by hand), and it grants exactly one capability: "start a chat with this workspace." That's all it should grant.

The actual authentication is a separate session token, and it's handled the way secrets should be:

Chat session token (pseudocode)
raw = base64url(random_bytes(32))          # returned to the browser exactly once
store sha256(raw) in the sessions table    # server keeps only the hash
# 30-day rolling expiry; every use extends it

The raw token goes to the browser once and lives in the iframe's storage; the server persists only its SHA-256, so a database leak yields hashes, not usable sessions. Splitting "who are you talking to" (the public embed key) from "prove this session is yours" (the secret token) keeps the public identifier boring and the real credential scarce.

Where this is heading

The web platform keeps making this kind of embed cheaper and safer:

  • Better cross-origin isolation primitives. Fenced frames and the Permissions Policy are steadily giving embedded content stronger, more declarative boundaries than the sandbox flags we lean on today — a direction that suits a widget whose whole job is to be a good guest.
  • Streams as the default transport. Consuming SSE over fetch and the Streams API — rather than the older EventSource object — is becoming the ordinary way to do server push precisely because it composes with headers, cancellation, and everything else fetch already does. The awkward detour in this post is on its way to being the obvious path.
  • Storage partitioning as the norm. Third-party iframes increasingly run with partitioned or denied storage by default. Designing the widget to degrade to session-only when storage throws — instead of assuming it works — is the posture the whole ecosystem is moving toward.

The constant underneath all of it is the framing: you are a guest on someone else's page. Ship little, touch nothing you don't own, enforce your boundaries with the browser rather than with good intentions, and let the sensitive data stay on your origin where it belongs.

Further reading