A chat widget that won't touch your site
- #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:
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 itsMutationObserverand 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:
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:
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 delayThe 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:
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:
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, idempotentlyThe 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:
# 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:
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 itThe 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
fetchand the Streams API — rather than the olderEventSourceobject — is becoming the ordinary way to do server push precisely because it composes with headers, cancellation, and everything elsefetchalready 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
- MDN Web Docs,
The iframe element
— the
sandboxandallowattributes and what each capability unlocks. - MDN Web Docs,
Using server-sent events
— the SSE wire format and the
EventSourceAPI it's usually read with. - MDN Web Docs,
Window: postMessage()
— targeted origins, the wildcard, and verifying
event.origin. - MDN Web Docs, Content-Security-Policy: frame-ancestors — controlling who may embed a page.
- PostgreSQL, NOTIFY — the publish/subscribe mechanism the live stream is built on.