Embedding a Web Chat Widget
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written on 2026-10-01 against the browser platform as documented by the WHATWG HTML and Fetch standards and
MDN on that date, React (createRoot, React 18 or later) and Vue 3 (defineCustomElement), with the DocsAssistant
backend on FastAPI 0.142.2 or Spring AI 2.0.1 on Spring Boot 4.1.1 (see the
version baseline). The snippets are illustrative: they are
written from the official documentation of each API and are not compiled or run by this repository, and no browser
testing was done for this page. Browser support and policy details are stated with the caveat that they must be
confirmed in the browsers you target.
The RAG API page already ends with a small widget: a fetch call that reads the SSE stream of POST /v1/ask and a
reminder to render text as text (see the
minimal browser widget). That snippet is a
function you call from your own page. This page does not repeat it; it extends it into something a different team can
put on its site with one line of HTML, without a build step and without its CSS or JavaScript colliding with
yours. The server side of the stream (event types, the contract) is in
Streaming Chat APIs; the protocol-level alternatives to the
hand-written client are in Chat UI Protocols, and the
off-the-shelf front ends in Ready-Made Chat UIs.
What "embeddable" adds
An in-app chat component runs in a page you control. An embeddable widget runs in a page you do not control, so every assumption changes.
| Requirement | Consequence for the design |
|---|---|
One-line install |
A single |
Style isolation both ways |
The host’s CSS must not restyle the widget and the widget’s CSS must not leak out: Shadow DOM, not global selectors. |
No framework assumptions |
The host may already ship React 17, Vue 2, jQuery or nothing. The default bundle is plain JavaScript with no dependencies. |
Cross-origin by definition |
The page is on |
Strict host policies |
Hosts often run a Content Security Policy. The widget must need only a documented, minimal set of directives. |
Accessible by default |
The host’s accessibility statement now includes your widget: keyboard, screen reader and reduced-motion behaviour are not optional extras. |
Versioned delivery |
The host pins a version, or accepts a moving URL. Either way the contract with |
Three ways to embed
| Variant | Strengths | Weaknesses | Use when |
|---|---|---|---|
Custom element with Shadow DOM, loaded by a script tag (this page’s main example) |
One line to install, no iframe sizing problems, inherits fonts and colours through CSS custom properties, can read page context (title, URL) if the host allows it. |
Runs with the host page’s privileges: a compromised widget script is a compromised host page. Needs host CSP changes. |
The host trusts you (same organisation or a contract) and wants a tight visual fit. |
|
A real security boundary: your origin, your CSP, your cookies. The host cannot read the conversation and vice versa. |
Fixed-size box, resize handshakes over |
Unknown or untrusted hosts, or authenticated chat that must keep its session private. |
Framework component (React or Vue package) |
Natural for apps that already use the framework, typed props, tree-shakable. |
Version coupling with the host’s framework, bundler required, no script-tag install. |
The host is a React or Vue application and you control or advise its code (see the variants below). |
Shadow DOM is a styling and DOM-encapsulation tool, not a security boundary: scripts on the page can reach an
open shadow root and the widget’s script executes with the host page’s origin. Only an iframe from another origin gives
isolation of that kind. Choose accordingly, and for the iframe variant jump to
the iframe alternative.
The stream client
The first piece is the part that talks to the API. It extends the minimal widget in four ways: it tolerates CRLF line
endings and comments, it turns a non-2xx response into an error using the problem-details type (RFC 9457), it
aborts a stalled stream, and it asks the host for a token instead of hard-coding one. EventSource is not an option
because POST /v1/ask has a body and an Authorization header; the stream is read with fetch and a ReadableStream
instead.
export async function askStream({ api, question, conversationId, token, signal, onEvent }) {
const headers = { "Content-Type": "application/json", Accept: "text/event-stream" };
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(api, {
method: "POST",
headers,
body: JSON.stringify({ question, conversationId }),
signal,
});
if (!res.ok) {
// Problem details (application/problem+json): show a stable message, never the raw body.
const problem = await res.json().catch(() => ({}));
throw Object.assign(new Error(problem.type ?? `http-${res.status}`), { status: res.status });
}
if (!res.body) throw new Error("streaming-unsupported");
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let end;
// A blank line ends an event; accept both LF LF and CRLF CRLF (lone CR is not handled).
while ((end = buffer.search(/\r?\n\r?\n/)) >= 0) {
const block = buffer.slice(0, end);
buffer = buffer.slice(end).replace(/^\r?\n\r?\n/, "");
let event = "message";
const data = [];
for (const line of block.split(/\r?\n/)) {
if (line.startsWith(":")) continue; // comment / keep-alive
const i = line.indexOf(":");
const field = i < 0 ? line : line.slice(0, i);
const val = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
if (field === "event") event = val;
else if (field === "data") data.push(val);
}
if (data.length) onEvent(event, JSON.parse(data.join("\n")));
}
}
}
Two behaviours differ from EventSource and have to be provided by the widget:
-
No automatic reconnect. If the connection drops mid-answer, the widget shows the partial answer with a "retry" button that sends the question again (a new turn). Resuming a stream with
Last-Event-IDneeds server support that the DocsAssistant core does not implement, so the widget does not pretend to. -
No built-in timeout. An idle timer that aborts the request when no byte (including a keep-alive comment) arrives for, say, 30 seconds keeps a dead proxy from leaving the UI in a permanent "typing" state.
AbortControlleris the tool for both the timer and the user’s "stop" button.
The same module is imported unchanged by the framework variants below; only the rendering differs.
The widget as a custom element
The widget is one classic script (not an ES module) that registers a <docs-assistant> element, attaches a Shadow DOM to
it and builds the UI with createElement and textContent only. A classic script is chosen on purpose:
document.currentScript is the way a script tag reads its own data-* attributes, and it is null inside module
scripts and after any await, so it is read once, synchronously, at the top. The code below inlines the client of the
previous section for brevity.
(() => {
const script = document.currentScript; // read synchronously; null for modules
const cfg = {
api: script.dataset.api, // https://api.example.org/v1/ask
tokenUrl: script.dataset.tokenUrl, // same-origin endpoint of the host, optional
title: script.dataset.title ?? "Docs assistant",
linkHosts: (script.dataset.linkHosts ?? "").split(",").filter(Boolean), // allowed citation hosts
};
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host { all: initial; font: 16px/1.4 system-ui, sans-serif; color-scheme: light dark;
--da-accent: #1a56db; --da-bg: Canvas; --da-fg: CanvasText; --da-radius: 12px; }
* { box-sizing: border-box; }
.launcher { position: fixed; right: 16px; bottom: 16px; min-width: 48px; min-height: 48px; border-radius: 24px;
border: 0; background: var(--da-accent); color: #fff; font: inherit; padding: 0 16px; cursor: pointer; }
.panel { position: fixed; right: 16px; bottom: 80px; width: min(380px, calc(100vw - 32px)); max-height: 70vh;
display: flex; flex-direction: column; background: var(--da-bg); color: var(--da-fg);
border: 1px solid GrayText; border-radius: var(--da-radius); }
.panel[hidden] { display: none; }
h2 { font-size: 1rem; margin: 0; padding: 12px; border-bottom: 1px solid GrayText; }
ol.transcript { list-style: none; margin: 0; padding: 12px; overflow: auto; flex: 1; }
li.msg { margin: 0 0 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
li.user { text-align: right; }
form { display: flex; gap: 8px; padding: 12px; border-top: 1px solid GrayText; }
input { flex: 1; min-height: 44px; font: inherit; padding: 0 8px; }
button { min-height: 44px; font: inherit; }
:focus-visible { outline: 3px solid var(--da-accent); outline-offset: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: no-preference) { ol.transcript { scroll-behavior: smooth; } }
`);
const h = (tag, props = {}, ...kids) => {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k === "onclick" || k === "onsubmit" || k === "onkeydown") el.addEventListener(k.slice(2), v);
else el.setAttribute(k, v);
}
el.append(...kids); // strings become Text nodes, never HTML
return el;
};
class DocsAssistant extends HTMLElement {
#abort; #conversationId;
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.adoptedStyleSheets = [sheet];
this.launcher = h("button", { class: "launcher", type: "button", "aria-expanded": "false",
"aria-controls": "panel" }, cfg.title);
this.status = h("div", { class: "sr-only", role: "status" }); // polite live region
this.transcript = h("ol", { class: "transcript", "aria-label": "Conversation" });
this.input = h("input", { type: "text", name: "q", maxlength: "4000", autocomplete: "off",
"aria-label": "Your question" });
this.stop = h("button", { type: "button", hidden: "", onclick: () => this.#abort?.abort() }, "Stop");
const form = h("form", { onsubmit: (e) => { e.preventDefault(); this.#send(); } },
this.input, h("button", { type: "submit" }, "Send"), this.stop);
this.panel = h("div", { id: "panel", role: "dialog", "aria-labelledby": "title", hidden: "",
class: "panel", onkeydown: (e) => { if (e.key === "Escape") this.#toggle(false); } },
h("h2", { id: "title" }, cfg.title), this.transcript, form);
this.launcher.addEventListener("click", () => this.#toggle(this.panel.hidden));
root.append(this.launcher, this.panel, this.status);
}
disconnectedCallback() { this.#abort?.abort(); }
#toggle(open) {
this.panel.hidden = !open;
this.launcher.setAttribute("aria-expanded", String(open));
(open ? this.input : this.launcher).focus(); // move focus in on open, back out on close
}
async #token() {
if (!cfg.tokenUrl) return null;
const res = await fetch(cfg.tokenUrl, { credentials: "same-origin" }); // host's own origin
return res.ok ? (await res.json()).token : null;
}
async #send() {
const question = this.input.value.trim();
if (!question || this.#abort) return;
this.input.value = "";
this.transcript.append(h("li", { class: "msg user" }, question));
const answer = h("li", { class: "msg bot" });
this.transcript.append(answer);
let text = "";
this.#abort = new AbortController();
this.stop.hidden = false;
this.status.textContent = "Searching the documentation";
try {
await askStream({
api: cfg.api, question, conversationId: this.#conversationId, token: await this.#token(),
signal: this.#abort.signal,
onEvent: (event, p) => {
if (event === "token") { text += p.text; answer.textContent = text; }
else if (event === "citations") answer.replaceChildren(...renderAnswer(text, p, cfg.linkHosts));
else if (event === "done") this.#conversationId = p.conversationId;
else if (event === "error") throw new Error(p.type);
},
});
this.status.textContent = text; // announce the finished answer once
} catch (err) {
if (err.name !== "AbortError") answer.textContent = "Sorry, the assistant is unavailable. Please try again.";
this.status.textContent = err.name === "AbortError" ? "Stopped" : "The assistant is unavailable";
} finally {
this.#abort = undefined;
this.stop.hidden = true;
this.transcript.scrollTop = this.transcript.scrollHeight;
}
}
}
if (!customElements.get("docs-assistant")) customElements.define("docs-assistant", DocsAssistant);
document.body.append(document.createElement("docs-assistant"));
})();
Source: MDN — Using custom elements, MDN — Using shadow DOM, MDN — Document.currentScript and MDN — Document.adoptedStyleSheets.
The host installs it with one tag. defer keeps the script from blocking parsing and guarantees document.body exists
when it runs; integrity pins the exact file for a versioned URL (see delivery and
versioning):
<script src="https://widget.example.org/1.4.0/docs-assistant.js"
integrity="sha384-REPLACE_WITH_THE_PUBLISHED_HASH" crossorigin="anonymous" defer
data-api="https://api.example.org/v1/ask"
data-token-url="/assistant/token"
data-title="Ask the docs"
data-link-hosts="docs.example.org"></script>
Source: MDN — Subresource Integrity.
| Attribute | Meaning |
|---|---|
|
Absolute URL of |
|
A same-origin endpoint of the host that returns |
|
Launcher label and dialog heading. |
|
Comma-separated hostnames citation links may point to; anything else is rendered as plain text. |
Shadow DOM details that matter
-
Styling. Selectors in the shadow root do not match outside it and the page’s selectors do not match inside, with two intended exceptions: inherited properties (font, colour) and CSS custom properties cross the boundary.
:host { all: initial }resets the inherited properties so the host page’sbodystyles do not leak in, and--da-accentand friends are the documented theming surface: the host sets them on thedocs-assistantelement. For finer control, expose pieces withpartandexportparts. -
Fonts. Declare
@font-facerules in the host document, or use system fonts, as above; font declarations inside a shadow tree are not reliable across browsers, so confirm before depending on them. -
Identifiers.
idreferences (aria-labelledby,aria-controls,<label for>) resolve only within one shadow tree. That is why the panel, its heading and the launcher all live in the same root, and why citation markers use buttons with click handlers rather thanhref="#source-2": fragment navigation does not look inside shadow roots. -
Events. Events that cross the boundary are retargeted to the host element, so the page’s analytics sees a click on
docs-assistant, not on the inner button. Dispatch explicitCustomEvent`s (`composed: true) for the things the host may want to observe, such asassistant-answer-complete. -
Forms. An
<input>inside a shadow root does not take part in a surrounding host<form>, which is what you want here; password managers and autofill tools may also skip it.
Rendering answers and citations
Model output is untrusted text. A retrieved document can carry a prompt injection that makes the model emit
<img src=x onerror=…> or a javascript: link, and innerHTML turns that into script execution in the host page’s
origin. Three rules keep the widget safe, and they are also what makes it work under a strict CSP:
-
Set content with
textContentorappend(string), neverinnerHTML,outerHTMLorinsertAdjacentHTML. -
Build links from the server’s resolved
citationspayload only, never by parsing URLs out of the answer text, and validate each URL before it becomes anhref. -
If you must show Markdown, convert it with a library that outputs a DOM tree or sanitise its HTML with a maintained sanitiser (DOMPurify is the common one) before inserting it, and enable Trusted Types in your own test pages so any forgotten sink throws.
The renderer used by the widget above splits the answer on the [n] markers the server inserts (the contract is in
Citations & Grounding) and appends a numbered source list:
function safeUrl(raw, linkHosts = []) {
try {
const url = new URL(raw, location.href);
const okProtocol = url.protocol === "https:"; // http: only if you really need it
const okHost = linkHosts.length === 0 || linkHosts.includes(url.hostname);
return okProtocol && okHost ? url.href : null;
} catch {
return null;
}
}
function renderAnswer(text, citations, linkHosts) {
const byMarker = new Map(citations.map((c) => [String(c.marker), c]));
const nodes = [];
const list = h("ol", { class: "sources", "aria-label": "Sources" });
for (const part of text.split(/(\[\d+\])/)) {
const marker = /^\[(\d+)\]$/.exec(part)?.[1];
if (!marker || !byMarker.has(marker)) { nodes.push(document.createTextNode(part)); continue; }
const target = h("li", { value: marker, tabindex: "-1" });
const c = byMarker.get(marker);
const href = safeUrl(c.url, linkHosts);
target.append(href
? h("a", { href, target: "_blank", rel: "noopener noreferrer" }, c.title)
: document.createTextNode(c.title));
if (c.quote) target.append(h("blockquote", {}, c.quote));
list.append(target);
nodes.push(h("sup", {}, h("button", {
type: "button", "aria-label": `Source ${marker}: ${c.title}`,
onclick: () => target.focus(), // focus the source item inside the same shadow tree
}, marker)));
}
return list.children.length ? [...nodes, list] : nodes;
}
Source: MDN — Node.textContent, MDN — URL, MDN — rel="noopener" and W3C — Trusted Types.
Design decisions worth keeping:
-
An unverifiable or disallowed citation is shown as text, not dropped, so the answer stays honest about its sources and the failure is visible in testing.
-
target="_blank"always comes withrel="noopener noreferrer", and the link text is the document title, never a URL that could be mistaken for a different site. -
A marker without a matching citation stays as plain text instead of becoming a dead button.
-
Grounding verdicts (the
groundingevent) can add a visible "not fully supported by sources" note; how that verdict is computed is in Hallucination Detection & Correction, and the guardrails against prompt injection in Guardrails & Prompt Injection.
Accessibility
A chat widget concentrates the hard parts of dynamic-content accessibility: content that changes while the user is doing something else, a panel that appears over the page, and a stream that would otherwise be read out token by token. The general principles, standards and validation tools are in Accessibility; the points specific to this widget follow. The conformance target is WCAG 2.2 level AA.
| Concern | What the widget does |
|---|---|
Launcher |
A real |
Opening the panel |
The panel has |
Closing |
|
Streaming text |
Appending every token to a live region makes screen readers announce fragments, interrupt each other and repeat. The
transcript is therefore not a live region. A separate visually hidden |
New messages |
Focus is never moved to a new answer automatically: that would steal the cursor from the input where the user is about to type the follow-up. Status messages are announced without moving focus, as WCAG 2.2 criterion 4.1.3 requires. |
Stop and retry |
Both are buttons that appear in the tab order; "Stop" is hidden when nothing is streaming and is a keyboard-operable equivalent of cancelling the request. |
Citations |
Marker buttons have accessible names that include the source title. The source list is an ordered list with an
|
Colour and motion |
|
Zoom and reflow |
The panel width is |
Source: W3C — Web Content Accessibility Guidelines 2.2, W3C APG — Dialog (Modal) pattern and MDN — ARIA live regions.
Automated checkers find missing names and contrast problems but not whether the stream is bearable to listen to; manual keyboard and screen-reader passes are part of the release checklist, as explained in Accessibility. For the framework variants, the Vue guide has its own notes on Security and Accessibility, and automated browser tests that exercise the widget in a real browser are covered in End-to-End Testing in Real Browsers.
The React variant
When the host is a React application, ship the widget as a component, and reuse the stream client unchanged. The state machine lives in a custom hook (see Custom Hooks); cancellation is tied to the component’s lifetime with an effect cleanup (see Effects).
import { useCallback, useEffect, useReducer, useRef } from "react";
import { askStream } from "./docs-assistant-client.js";
function reducer(state, action) {
switch (action.type) {
case "ask": return { ...state, busy: true, messages: [...state.messages,
{ role: "user", text: action.text }, { role: "assistant", text: "", citations: [] }] };
case "token": return { ...state, messages: state.messages.map((m, i) =>
i === state.messages.length - 1 ? { ...m, text: m.text + action.text } : m) };
case "citations": return { ...state, messages: state.messages.map((m, i) =>
i === state.messages.length - 1 ? { ...m, citations: action.citations } : m) };
case "done": return { ...state, busy: false, conversationId: action.conversationId };
case "error": return { ...state, busy: false, error: action.message };
default: return state;
}
}
export function useDocsAssistant({ api, getToken }) {
const [state, dispatch] = useReducer(reducer, { messages: [], busy: false });
const controller = useRef(null);
useEffect(() => () => controller.current?.abort(), []); // cancel on unmount
const send = useCallback(async (question) => {
controller.current = new AbortController();
dispatch({ type: "ask", text: question });
try {
await askStream({
api, question, conversationId: state.conversationId, token: await getToken?.(),
signal: controller.current.signal,
onEvent: (event, p) => {
if (event === "token") dispatch({ type: "token", text: p.text });
else if (event === "citations") dispatch({ type: "citations", citations: p });
else if (event === "done") dispatch({ type: "done", conversationId: p.conversationId });
else if (event === "error") throw new Error(p.type);
},
});
} catch (e) {
if (e.name !== "AbortError") dispatch({ type: "error", message: "The assistant is unavailable." });
else dispatch({ type: "done", conversationId: state.conversationId });
}
}, [api, getToken, state.conversationId]);
return { ...state, send, stop: () => controller.current?.abort() };
}
export function DocsAssistant({ api, getToken, linkHosts = [] }) {
const { messages, busy, error, send, stop } = useDocsAssistant({ api, getToken });
return (
<section aria-labelledby="da-title">
<h2 id="da-title">Ask the docs</h2>
<ol aria-label="Conversation">
{messages.map((m, i) => (
<li key={i}>
{m.text /* JSX escapes text: no HTML is ever parsed */}
{m.citations?.map((c) => (
<a key={c.marker} href={safeUrl(c.url, linkHosts)} rel="noopener noreferrer" target="_blank">{c.title}</a>
))}
</li>
))}
</ol>
{error && <p role="alert">{error}</p>}
<form onSubmit={(e) => { e.preventDefault(); const q = new FormData(e.currentTarget).get("q"); e.currentTarget.reset(); send(String(q)); }}>
<input name="q" aria-label="Your question" />
<button type="submit" disabled={busy}>Send</button>
{busy && <button type="button" onClick={stop}>Stop</button>}
</form>
</section>
);
}
safeUrl is the same helper as above, given the same citation-host allow-list as the framework-free widget (here a
linkHosts prop, read from a link-hosts attribute when mounted as a custom element); React escapes text children but
does not stop a javascript: URL in href. To offer the React version through the script tag too, mount it inside
the same kind of custom element, so the styling isolation is kept (React attaches its event listeners to the root container, which here is inside the shadow tree):
import { createRoot } from "react-dom/client";
class ReactDocsAssistant extends HTMLElement {
#root;
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = WIDGET_CSS; // a bundler's global CSS would land in <head>, outside the shadow tree
const mount = document.createElement("div");
shadow.append(style, mount);
this.#root = createRoot(mount);
const linkHosts = (this.getAttribute("link-hosts") ?? "").split(",").filter(Boolean);
this.#root.render(<DocsAssistant api={this.getAttribute("api")} linkHosts={linkHosts} />);
}
disconnectedCallback() { this.#root?.unmount(); }
}
customElements.define("react-docs-assistant", ReactDocsAssistant);
Source: React — createRoot.
A <style> element created by script needs a matching CSP style-src allowance (nonce or hash), unlike the constructed
stylesheet of the framework-free version; that is a real cost of the framework route. Fetching data and cancellation
patterns in general are in Data Fetching, and project setup for a bundled build in
Getting Started. Teams on Angular can implement the same client behind a service
(RxJS and Async); Angular’s HttpClient is not the right tool for a streamed
POST body, so the fetch client is wrapped in an Observable instead.
The Vue variant
In Vue the same split is a composable (see Composables) and an SFC, and Vue can compile a component to a custom element with its own Shadow DOM, which makes the script-tag distribution almost free.
import { onScopeDispose, ref } from "vue";
import { askStream } from "./docs-assistant-client.js";
export function useDocsAssistant({ api, getToken }) {
const messages = ref([]);
const busy = ref(false);
let conversationId;
let controller;
async function send(question) {
controller = new AbortController();
busy.value = true;
messages.value.push({ role: "user", text: question });
const answer = { role: "assistant", text: "", citations: [] };
messages.value.push(answer);
const reactive = messages.value.at(-1); // the reactive proxy of the object just pushed
try {
await askStream({
api, question, conversationId, token: await getToken?.(), signal: controller.signal,
onEvent: (event, p) => {
if (event === "token") reactive.text += p.text;
else if (event === "citations") reactive.citations = p;
else if (event === "done") conversationId = p.conversationId;
else if (event === "error") throw new Error(p.type);
},
});
} catch (e) {
if (e.name !== "AbortError") reactive.text = "The assistant is unavailable.";
} finally {
busy.value = false;
}
}
onScopeDispose(() => controller?.abort()); // cancel when the component unmounts
return { messages, busy, send, stop: () => controller?.abort() };
}
<script setup>
import { ref } from "vue";
import { useDocsAssistant } from "./useDocsAssistant.js";
import { safeUrl } from "./safe-url.js";
const props = defineProps({ api: String, linkHosts: { type: String, default: "" } });
const linkHosts = props.linkHosts.split(",").filter(Boolean); // allowed citation hosts, as in the script-tag widget
const { messages, busy, send, stop } = useDocsAssistant({ api: props.api });
const question = ref("");
function submit() { const q = question.value.trim(); if (q) { question.value = ""; send(q); } }
</script>
<template>
<section aria-labelledby="da-title">
<h2 id="da-title">Ask the docs</h2>
<ol aria-label="Conversation">
<li v-for="(m, i) in messages" :key="i">
{{ m.text }}
<a v-for="c in m.citations" :key="c.marker" :href="safeUrl(c.url, linkHosts)" target="_blank" rel="noopener noreferrer">{{ c.title }}</a>
</li>
</ol>
<form @submit.prevent="submit">
<input v-model="question" aria-label="Your question" />
<button type="submit" :disabled="busy">Send</button>
<button v-if="busy" type="button" @click="stop">Stop</button>
</form>
</section>
</template>
<style>
section { font: 16px/1.4 system-ui, sans-serif; }
</style>
import { defineCustomElement } from "vue";
import DocsAssistant from "./DocsAssistant.ce.vue";
customElements.define("vue-docs-assistant", defineCustomElement(DocsAssistant));
Source: Vue — Vue and Web Components, Vue — Composables and Vue — Security.
Files named *.ce.vue have their styles inlined into the element’s shadow root, which solves the CSS placement problem
the React route has to handle by hand. Mustache interpolation ({{ }}) escapes text, and v-html is the Vue
equivalent of innerHTML: do not use it on model output. Vue does not sanitise URL bindings either, so safeUrl is
still needed, with the allow-list passed as the link-hosts attribute of the custom element (the Vue security guide
describes this injection class). More background in
Vue.js, Getting Started and
Rendering, Render Functions and Web Components.
Content Security Policy
The host page, not the widget, decides the policy, so the widget documents what it needs and works with a strict policy. For the script-tag custom element above the host’s policy needs only the following.
| Directive | Needed value | Why |
|---|---|---|
|
|
The widget script itself. With |
|
|
The |
|
Nothing for the constructed stylesheet; a nonce or hash for a |
Stylesheets built with |
|
Only if the theme loads avatars or icons or custom fonts |
The default widget uses none. |
|
Optional |
The widget has no |
|
Only for the iframe variant |
The host must allow the widget’s origin to be framed by itself. |
Practical guidance: ship the widget’s required directives in its documentation as a copy-paste block, roll a new
policy out with Content-Security-Policy-Report-Only first, and send violation reports to a place someone reads.
Avoid designs that need 'unsafe-inline' or 'unsafe-eval' (no template compilers at runtime, no eval, no inline
event-handler attributes in generated markup), because hosts that care about CSP will refuse the widget otherwise.
The Vue runtime-only build and React’s production build both satisfy that, but a Vue build that compiles templates at
runtime does not.
CORS
The widget runs on www.example.com and calls api.example.org, so every request is cross-origin. A POST with a
JSON Content-Type and an Authorization header is not a "simple" request, so the browser first sends an OPTIONS
preflight and only sends the real request if the answer allows it. The mechanics, the headers and the common mistakes
are in CORS; the DocsAssistant specifics follow.
Rules for the API:
-
Allowlist origins, do not reflect them. Keep a list of origins registered by hosts (per tenant if you have tenants), answer with the exact matching origin in
Access-Control-Allow-Origin, and addVary: Originso caches do not serve one host’s answer to another. Echoing back whateverOriginarrives is equivalent to*and defeats the point. -
Prefer tokens to cookies. The wildcard
*is not accepted together with credentials, and a cookie-authenticated third-party API runs into third-party cookie restrictions. The design above gets a short-lived bearer token from the host’s backend (same origin, so the host’s session cookie is used there) and sends it inAuthorization, which needs noAllow-Credentials. The token flows are in Browser-Based Apps. -
Allow what the widget sends, nothing more:
POST(andOPTIONS), headersAuthorizationandContent-Type(plusIdempotency-Keyif you use it). -
Expose what the widget reads. Only a few response headers are readable by cross-origin scripts by default; if the client reads
X-Trace-Idfor a "report a problem" link, list it inAccess-Control-Expose-Headers. -
Cache the preflight.
Access-Control-Max-Ageavoids one extra round trip per question; browsers cap the value (MDN documents different upper limits for Chromium and Firefox), so a value of a few thousand seconds is enough. -
CORS is not authentication. It stops other sites' scripts from reading responses in a visitor’s browser; it does not stop a script or
curlfrom calling the API. Authentication, authorisation and rate limits still apply (see Access Control & Privacy and Serving RAG as an API).
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://www.example.com", "https://docs.example.org"], # loaded from configuration
allow_methods=["POST"],
allow_headers=["Authorization", "Content-Type", "Idempotency-Key"],
expose_headers=["X-Trace-Id"],
max_age=3600,
)
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.cors(Customizer.withDefaults()) // uses the CorsConfigurationSource bean below
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource(WidgetProperties props) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(props.allowedOrigins()); // exact origins from configuration
config.setAllowedMethods(List.of("POST", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "Idempotency-Key"));
config.setExposedHeaders(List.of("X-Trace-Id"));
config.setMaxAge(Duration.ofHours(1));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/v1/**", config);
return source;
}
Source: Spring Security — CORS.
If the response is streamed through a reverse proxy or CDN, also check that it does not buffer text/event-stream and
that the CORS headers are added to the real response and the preflight alike; a proxy that answers OPTIONS itself
must agree with the application’s allowlist.
The iframe alternative
When the host is not trusted, or the conversation is authenticated with your own cookies, serve the chat as a page on
your origin and embed that page. The host-facing snippet shrinks to an iframe, the CSP that matters is the one your
chat page sends, and the cross-origin problems become postMessage problems:
<iframe src="https://chat.example.org/embed?site=docs" title="Ask the docs"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allow="clipboard-write" loading="lazy"
style="border:0; width:380px; height:560px"></iframe>
<!-- chat.example.org response header: only these sites may frame it -->
<!-- Content-Security-Policy: frame-ancestors https://www.example.com https://docs.example.org -->
Source: MDN — iframe and MDN — frame-ancestors.
-
The
titleis required for an accessible frame; focus has to be managed across the frame boundary (the launcher can live in the host page and move focus into the frame on open). -
frame-ancestorsis the modern replacement forX-Frame-Optionsand is controlled by the framed page; list only registered host origins. -
Send messages with
postMessage(data, "https://chat.example.org")using the exact target origin, and on receipt checkevent.originbefore acting onevent.data; never use"*"for messages that carry tokens. -
Browsers partition or block third-party cookies and storage in frames. If the chat page depends on cookies set when the visitor logged in on your site directly, test it in the browsers you support, or pass a short-lived token in the URL fragment or a
postMessageinstead of relying on ambient cookies. -
Resizing needs a message handshake (
heightevents from the frame), or a fixed size with internal scrolling, as above.
Delivery and versioning
-
Immutable, versioned URLs. Publish
/1.4.0/docs-assistant.jswith a long cache lifetime and a hash for SRI; offer a/1/docs-assistant.jschannel for hosts that accept automatic minor updates, withoutintegrity. -
Additive API contract. The widget ignores unknown SSE events and unknown fields, so the server can add event types within
/v1, as described under versioning in Serving RAG as an API. -
Hosting. Static files from object storage plus a CDN; the API runs where the core runs (Deployment Patterns).
-
Observability. Send the
traceIdfrom thedoneevent with user feedback so a "this answer was wrong" click links to a trace (Observability & Tracing); the widget itself should report only coarse client errors, not conversation text, to respect privacy. -
Testing. Run the widget in a real browser against a fixture page that has hostile CSS (
* { all: unset }, abutton { display: none }rule), a strict CSP and a different origin from the API; the point of the tests is the host environment, not the widget’s own logic. -
Where the conversation lives. Keep the
conversationIdin memory (as above) or insessionStorageif the host wants the chat to survive navigation; do not store tokens or transcripts inlocalStorageon a page you do not own.
Checklist
| Area | Check |
|---|---|
Install |
One script tag with |
Isolation |
Shadow DOM, theming through custom properties, no global selectors, no globals other than the custom element name. |
Rendering |
No |
Streaming |
|
Accessibility |
Focus in on open and back on close, |
CSP |
Documented |
CORS |
Origin allowlist with |
Operations |
Versioned immutable files, additive contract, traceId on feedback, hostile-page browser tests. |