跳到正文
DEV Community· SupportKit AI·· 3 小時前AI 評分56

構建可嵌入他人網站的 AI 聊天小工具時,我學到的 5 件事

5 things I learned building an embeddable AI chat widget that runs on other people's websites

AI 導讀

SupportKit AI 作者分享構建可嵌入他人網站的 AI 支援聊天小工具時,處理第三方網站環境所學到的 5 項實作經驗。文章建議以 Shadow DOM 隔離樣式、用 textContent 顯示回答,並透過來源允許清單、伺服器端檢查及按 IP 限流降低安全與濫用風險,亦介紹語言和深色模式設定。

正文

I recently built an AI support chatbot for websites: a site owner uploads their FAQ, gets an assistant, and drops one <script> tag on their site. A chat bubble appears in the corner and answers visitors' questions.

The AI part was the easy bit. The hard part was that my code now runs inside websites I don't control. Here are the five things that took the most time to get right.

1. Use Shadow DOM, or the host site's CSS will break you

The first version used a normal <div>. On the very first test site, the host's global styles (button { ... }, * { ... }) turned my widget into a mess.

The fix is to render everything inside a Shadow DOM and reset styles at the root:

const host = document.createElement("div");
host.id = "my-widget";
document.body.appendChild(host);

const root = host.attachShadow({ mode: "open" });

const style = document.createElement("style");
style.textContent = ":host{all:initial} *{box-sizing:border-box;font-family:system-ui,sans-serif}";
root.appendChild(style);

:host { all: initial } stops inherited styles from leaking in, and nothing you put inside the shadow root leaks out to the page.

2. Never render the AI's answer as HTML

It's tempting to use innerHTML so the bot can return bold text or links. Don't. The answer is built from text the site owner pasted in and text the visitor typed, and both can contain something like <img src=x onerror=...>.

Every message is created with textContent:

function el(tag, className, text) {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text) node.textContent = text; // never innerHTML
  return node;
}

You lose rich formatting, but you can't get XSS through the chat. For a support widget that's a good trade.

3. Lock the widget to the sites the owner allows

The widget is identified by a public key in the script tag, so anyone can copy it onto their own site and burn the owner's AI credits. Each assistant therefore has an allow-list of origins, and the API only answers those:

export function corsHeaders(origin: string | null, allowedOrigins: string[]) {
  if (!origin || !allowedOrigins.includes(origin)) return null; // no CORS → browser blocks it
  return {
    "Access-Control-Allow-Origin": origin,
    "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type",
    Vary: "Origin",
  };
}

Two details that bit me:

  • Normalise origins when the owner saves them. People paste https://shop.com/some/page/ — store new URL(value).origin, and only allow https: (plain http: just for localhost).
  • Same-origin requests have no Origin header. When the widget runs on the app's own marketing page, a GET arrives without Origin and gets rejected. Sec-Fetch-Site: same-origin is set by the browser and can't be faked by a page, so it's safe to use for that case.

CORS only protects against browsers, so the server still checks the allow-list and rate-limits, which brings me to:

4. Rate-limit the public endpoint per IP

The chat endpoint is public by design, which makes it an easy target. Every message costs real money in AI tokens. Per-IP rate limits on the widget's chat and config endpoints, plus monthly reply limits per plan enforced on the server, keep one bad actor from draining an account.

Also: send no cookies. The widget uses fetch(..., { credentials: "omit" }), because an anonymous widget has no reason to carry the visitor's cookies for your domain.

5. Make it feel native: language and dark mode

Small things made the biggest difference in how "finished" it felt:

  • Language: the widget sends the visitor's Accept-Language, and the server returns the UI strings (placeholder, "Send", status line) in that language.
  • Dark mode: a few extra CSS rules inside @media (prefers-color-scheme: dark) in the shadow root, with a data-theme="light|dark" attribute for sites that want to force one.
if (theme === "dark") style.textContent += dark;
else if (theme !== "light") style.textContent += "@media (prefers-color-scheme: dark){" + dark + "}";

Wrapping up

If you build anything that runs on other people's sites, assume the page is hostile: isolate your styles, never trust text, restrict who can call you, and rate-limit everything that costs money.

I ended up packaging the whole thing — widget, Next.js 16 dashboard, Stripe billing, Apple/Google/passwordless login and 100+ tests — as a boilerplate called SupportKit AI. There's a 1-minute demo here if you're curious: https://supportkit-ai.netlify.app

Happy to answer questions about any of these in the comments.

來源:DEV Community · dev.to