Czat na stronę www: co się psuje, gdy piszesz własny widget
Tworzenie stron wwwJavascriptprogramowanie

Czat na stronę www: co się psuje, gdy piszesz własny widget

Czat na stronę www zaczyna się od przycisku w rogu ekranu, okienka, pola tekstowego i jednego fetch do API. Taką wersję napiszesz w wieczór. Nasza ma dziś ponad trzy tysiące linii TypeScriptu bez frameworka. Spora część tego kodu pilnuje spraw, o których przy pierwszej wersji się nie myśli: cudzego CSS-a, Safari, zerwanych połączeń i rachunku za model.

Piszę o czacie, który widzisz w prawym dolnym rogu tej strony. To nasz Asystent AI. Budujemy go od końca września 2026 r. i nie jest jeszcze gotowy dla klientów, dlatego adresu tu nie podaję. Na webowiec.net działa już na żywo i prawie wszystko z tego tekstu sprawdzisz sam: kliknij przycisk i zajrzyj do narzędzi deweloperskich w przeglądarce.

Dwa kawałki kodu: przycisk na stronie i okno w iframe

Od pierwszej decyzji zależy cała reszta: gdzie ma działać okno czatu. Jeśli wstawisz je wprost do strony, jej CSS wejdzie do twojego okna, a każdy skrypt na stronie przeczyta rozmowę. Dlatego czat składa się u nas z dwóch części.

Loader to mały skrypt, który właściciel strony wkleja do kodu. Rysuje przycisk w Shadow DOM, czyli w osobnym drzewie elementów, do którego nie sięgają selektory strony. Po kompresji gzip ma 5,5 KB.

Okno czatu to iframe z naszej domeny, z własnym HTML-em, CSS-em i JavaScriptem (sam skrypt ma 13,6 KB po kompresji). Tu jest pole tekstowe, historia rozmowy i cała komunikacja z API.

<script async src="https://czat.example.com/w/pk_KLUCZ.js"></script>

Wystarczy ta jedna linia, bo serwer generuje plik loadera osobno dla każdego klucza i wkleja konfigurację (kolor, pozycję, napis na przycisku) przed kodem. Przycisk rysuje się bez dodatkowego zapytania o ustawienia. Plik ma max-age=300, a adres iframe'a zawiera skrót treści. Po wdrożeniu nowa wersja dociera do stron klientów zwykle w kilka minut i nikt nie musi niczego wklejać od nowa.

Iframe powstaje dopiero przy pierwszym otwarciu czatu. Kto nie kliknie przycisku, pobiera sam loader, a jego przeglądarka nie dostaje identyfikatora rozmowy.

Shadow DOM nie chroni przed wszystkim. Właściwości dziedziczone, takie jak font-family i color, przechodzą przez jego granicę. Style loadera zaczynają się od resetu:

:host { all: initial; }

Przycisk ma z-index: 2147483000, bo na cudzej stronie zawsze znajdzie się baner z z-index: 99999. A jeśli strona wysyła nagłówek Content-Security-Policy, czat w ogóle się nie wczyta, dopóki jej właściciel nie dopisze domeny czatu do script-src, connect-src i frame-src. Na webowiec.net też musieliśmy to zrobić i dałbym to w pierwszym punkcie instrukcji instalacji.

Strona i iframe rozmawiają tylko przez postMessage

Okno czatu jest w innej domenie i ze stroną łączy je tylko postMessage. Najpierw iframe wysyła asystent:ready, a dopiero potem loader oddaje mu dane: identyfikator urządzenia, adres i tytuł podstrony. Wiadomość wysłana wcześniej przepadłaby, bo w ramce nic jej jeszcze nie odbiera. I loader, i iframe sprawdzają przy tym nadawcę:

window.addEventListener("message", (event) => {
  if (!frame || event.source !== frame.contentWindow || event.origin !== cfg.origin) return;
  // ...
});

Wysyłka też podaje adresata zamiast "*". Inaczej identyfikator dostałby każdy, komu uda się podmienić adres ramki.

Może cię zdziwić, że identyfikator w ogóle idzie tą drogą. Zwykle trzymałoby się go w ciasteczku okna czatu, ale przeglądarki blokują albo izolują ciasteczka w ramkach z obcej domeny. Dlatego loader zapisuje go na stronie klienta, w ciasteczku i w localStorage, a iframe wysyła go do API w nagłówku Authorization. Każdy dostęp do localStorage jest w try/catch, bo gdy przeglądarka blokuje dane stron, sam odczyt potrafi rzucić wyjątek.

Reszta niespodzianek dotyczy klawiatury i fokusu:

  • Zdarzenia klawiatury nie przechodzą przez granicę ramki. Esc trzeba obsłużyć dwa razy: na stronie i w ramce, która prosi loader o zamknięcie okna.
  • Safari nie pozwala ramce z innej domeny ustawić fokusu bez kliknięcia w niej. Po otwarciu czatu fokus zostaje na ramce, a do pola prowadzi Tab. Z Safari na iPhonie mieliśmy już przeprawę przy geolokalizacji: opisuję ją w artykule „Mapy OpenStreetMap na stronie — czego nie widać w tutorialu”.
  • Na telefonie w ogóle nie stawiamy fokusu w polu, bo klawiatura zasłoniłaby powitanie.
  • Po zamknięciu okna fokus wraca tam, gdzie był. Fokus wewnątrz Shadow DOM strona widzi jako fokus na samym hoście, czyli elemencie, do którego drzewo jest podpięte. Loader musi to rozpoznać i zapamiętać przycisk.

Strony SPA zmieniają adres bez przeładowania, a czat powinien wiedzieć, na której podstronie jest rozmówca. U nas załatwia to setInterval, który co sekundę porównuje location.href. Wygląda to prymitywnie, ale dzięki temu nie ruszamy history.pushState na cudzej stronie.

Autogrow pola: dwie linijki i dziewięć wywołań

Autogrow to pole tekstowe, które rośnie razem z tekstem. Każdy pisze je tak samo:

function autoGrow() {
  textarea.style.height = "auto";
  textarea.style.height = `${Math.min(textarea.scrollHeight, 140)}px`;
}

Pierwsza linijka wygląda na zbędną, ale bez niej pole tylko rośnie. scrollHeight nie spada poniżej aktualnej wysokości pola i po skasowaniu tekstu zostałoby ono wysokie.

Trudniejsze jest to, że zdarzenie input działa tylko wtedy, gdy pisze człowiek. Przypisanie textarea.value = "..." z kodu go nie wywoła. U nas autoGrow() woła osiem miejsc poza input, między innymi czyszczenie pola po wysłaniu, powrót tekstu po błędzie, szkic po przeładowaniu i pytanie wstawione przyciskiem ze strony. Pominiesz jedno, a po wysłaniu długiej wiadomości puste pole zostanie wysokie na kilka linijek.

Przy tym polu wychodzą jeszcze trzy drobiazgi:

  • W ukrytym elemencie scrollHeight wynosi 0. Gdyby szkic wrócił do zamkniętego okna, pole dostałoby zerową wysokość. Zabezpiecza to min-height w CSS.
  • Enter wysyła wiadomość, ale tylko z warunkiem !e.isComposing. Bez niego Enter, który zatwierdza znak na klawiaturze japońskiej albo chińskiej, wyśle niedopisany tekst. Na telefonie enterkeyhint="send" zmienia napis na klawiszu.
  • Safari na iPhonie powiększa stronę po wejściu w pole z czcionką mniejszą niż 16 px. Pole powinno mieć te 16 px, nawet gdy reszta okna ma mniej.

Autogrow nie wymaga już JavaScriptu. Właściwość field-sizing: content robi to samo w CSS, a od czerwca 2026 r. obsługują ją Chrome, Safari i Firefox:

textarea { field-sizing: content; max-height: 140px; }

Zostajemy przy JavaScripcie, bo widget budujemy także pod Safari 15 i Chrome 90. W nowym projekcie zacząłbym od tej linijki, a JavaScript dodał tylko jako zapas dla starszych przeglądarek.

Kliknięcie w link niszczy okno czatu

Na stronie bez SPA, czyli na większości stron firmowych i blogów, przejście na inną podstronę zamyka iframe razem z jego pamięcią. Ktoś pisze długie pytanie, zagląda do cennika i wraca do pustego pola.

Tekst z pola trafia zatem przy każdej zmianie do sessionStorage ramki i wraca na następnej podstronie. Podobnie działa zminimalizowany czat: kafelek z polem zostaje w tej samej karcie, ale po przejściu na inną podstronę nie zabiera fokusu, bo człowiek przyszedł tam czytać. Historię rozmowy okno pobiera z serwera.

Gorzej, gdy rozmówca kliknie link w trakcie odpowiedzi modelu.

Odpowiedź AI nie może wisieć na jednym żądaniu

Najprostszy sposób to POST, który zwraca strumień odpowiedzi modelu. Działa, dopóki okno żyje. Przejście na inną podstronę zrywa połączenie, a z nim odpowiedź. Takim żądaniem serwer nie wyśle też niczego sam z siebie, np. wiadomości konsultanta, który przejął rozmowę.

Rozdzieliliśmy dwie drogi:

  1. POST /messages zapisuje wiadomość i od razu odpowiada kodem 202. Model rusza w tle, u nas przez after() z Next.js.
  2. Okno czatu trzyma otwarty strumień SSE, czyli długie połączenie HTTP, którym serwer wysyła zdarzenia typing, delta i message.

Model kończy odpowiedź nawet wtedy, gdy nikt jej nie czyta, a serwer zapisuje ją w bazie. Nowe okno pobiera historię, łączy się z nagłówkiem Last-Event-ID i dostaje wszystko, czego jeszcze nie widziało.

EventSource odpada, bo nie wyśle nagłówka Authorization. Strumień czytamy przez fetch:

const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) return true;
  buffer += value;
  let cut;
  while ((cut = buffer.indexOf("\n\n")) !== -1) {
    handleEvent(buffer.slice(0, cut));
    buffer = buffer.slice(cut + 2);
  }
}

Bufor jest potrzebny, bo kawałek z sieci nie kończy się tam, gdzie zdarzenie. TextDecoderStream pilnuje z kolei polskich liter. Litera „ą” ma w UTF-8 dwa bajty i może trafić na granicę dwóch kawałków. Osobne dekodowanie każdego kawałka dałoby w tym miejscu krzaczki, i to tylko czasami, a takiego błędu szukałbyś długo.

Przy takim układzie pojawiają się wyścigi, czyli błędy zależne od tego, co przyjdzie pierwsze:

  • Serwer najpierw zapisuje się na zdarzenia, a dopiero potem czyta brakujące wiadomości z bazy. W odwrotnej kolejności wiadomość zapisana między tymi krokami by przepadła.
  • Przez to tę samą wiadomość można dostać dwa razy. Klient trzyma mapę wyświetlonych numerów seq i drugi raz jej nie rysuje.
  • Wiadomość rozmówcy potrafi wrócić strumieniem szybciej niż odpowiedź na jego POST. Dymek z wiadomością w trakcie wysyłania albo znika od razu, albo czeka na swoją kopię z atrybutem data-pending-seq.
  • Po błędzie sieci okno czatu ponawia fetch jeszcze dwa razy z tym samym clientMsgId, czyli UUID nadanym w przeglądarce. Serwer rozpoznaje powtórkę i model nie odpowiada podwójnie. Gdy wszystko zawiedzie, tekst wraca do pola, a zrzut ekranu do podglądu.

Zostaje sieć, na którą nie masz wpływu. Firmowe proxy potrafią buforować strumień: połączenie wygląda na otwarte, a bajty nie dochodzą. Klient czeka 15 sekund na pierwszy bajt, a potem 60 sekund na każdy następny. Serwer co 25 sekund wysyła pusty komentarz : ping, żeby cisza nie wyglądała na awarię. Po czterech nieudanych próbach czat przechodzi na zwykłe odpytywanie co 4 sekundy i pyta tylko wtedy, gdy karta jest widoczna.

Przerwy między próbami rosną do 30 sekund i mają losowy dodatek, żeby po restarcie serwera wszyscy nie wrócili w tej samej sekundzie. Po stronie serwera potrzebne są Cache-Control: no-store, no-transform i X-Accel-Buffering: no, który wyłącza buforowanie w nginxie. Bez nich odpowiedź potrafi przyjść w całości na końcu.

Jedno ograniczenie naszego układu: zdarzenia idą przez EventEmitter w pamięci procesu. To wystarcza, dopóki aplikacja ma jeden proces webowy. Przy dwóch potrzebujesz wspólnego kanału zdarzeń, np. Redis pub/sub, bo strumień i model mogą trafić na różne procesy.

Strumień to podgląd, prawdą jest zapisana wiadomość

Serwer wysyła zdarzenie delta najwyżej raz na 80 ms i za każdym razem podaje cały dotychczasowy tekst, a nie sam przyrost:

for await (const part of result.textStream) {
  text += part;
  if (Date.now() - lastPush > 80) {
    lastPush = Date.now();
    publish(topics, { type: "delta", conversationId, replyTo, text });
  }
}

Klient podmienia zawartość dymka. Zgubione zdarzenie niczego nie psuje, bo następne niesie całość. Kosztuje to trochę transferu, ale u nas odpowiedź na czacie ma od jednego do czterech zdań. Przy długich tekstach wysyłałbym same przyrosty.

Na końcu przychodzi message z tekstem z bazy, a dymek ze strumienia znika. W historii zostaje tekst po czyszczeniu, bo serwer usuwa z niego m.in. niewidoczne znaki. Ten podział pomaga też osobom niewidomym. Historia ma role="log" i aria-live="polite", czyli czytnik ekranu sam czyta nowe wiadomości. Gdyby trafiał tam strumień, człowiek słyszałby ten sam początek zdania wiele razy. Dymek ze strumienia ma zatem aria-hidden="true", a czytnik dostaje gotową wiadomość.

Odpowiedź modelu traktuj jak dane od obcej osoby. Rozmówca może namówić model, żeby wypisał <img src=x onerror=...>, więc innerHTML odpada. Nasz mały parser rozumie pogrubienie, listy i linki, a elementy buduje przez createElement i append. Przechodzą tylko linki z https://, dzięki czemu adres javascript: odpada. Każdy link dostaje rel="noopener noreferrer nofollow ugc". Ceną jest drobna brzydota: zanim model domknie **, przez chwilę widać gołe gwiazdki. Wolę to niż parser, który zgaduje, co model napisze za moment.

Przy przewijaniu nasz kod ma niedoróbkę. Każda porcja tekstu przewija historię na dół, nawet gdy rozmówca przewinął w górę, żeby coś doczytać. Przy czterech zdaniach ledwo to widać. Przy dłuższych odpowiedziach trzeba najpierw sprawdzić, czy rozmówca jest przy dolnej krawędzi historii:

const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 40;
renderDelta(text);
if (atBottom) log.scrollTop = log.scrollHeight;

Model też zawodzi: przekracza czas (u nas 60 sekund), zwraca pustą odpowiedź albo pada u dostawcy. Żaden z tych stanów nie może zostawić człowieka przed kropkami „pisze”. Wyłączenie kropek jest w finally. Zamiast pustej odpowiedzi idzie gotowe zdanie z prośbą o napisanie jeszcze raz, a błąd trafia do rozmowy jako wiadomość systemowa: „Coś poszło nie tak po mojej stronie. Spróbuj za chwilę, wiadomość jest zapisana.”

Zrzut ekranu: iframe nie widzi strony

Pytanie „czemu to tak wygląda?” bez obrazka niewiele mówi. Dlatego w menu pod plusem przy polu mamy zrzut ekranu z mazakiem.

Przeglądarka ma do tego jedno natywne API, getDisplayMedia, ale pyta o zgodę, każe wybrać kartę albo ekran i nie działa na telefonach. Zostaje narysowanie strony w JavaScripcie.

Pierwsza przeszkoda wynika z układu całego czatu: okno w iframe nie ma dostępu do strony. Zrzut musi zrobić kod uruchomiony na stronie klienta, czyli loader. Biblioteka do zrzutów waży około 65 KB po kompresji, prawie dwanaście razy tyle co loader. Pobieramy ją dopiero po kliknięciu „Zrzut ekranu”.

const canvas = await html2canvas(document.documentElement, {
  x: window.scrollX,
  y: window.scrollY,
  width: document.documentElement.clientWidth,
  height: window.innerHeight,
  useCORS: true,
  imageTimeout: 4000,
  ignoreElements: (node) => node === chatHost,
  onclone: (doc) => {
    for (const node of doc.querySelectorAll(PRIVATE)) node.style.visibility = "hidden";
  },
});

Trzeba wiedzieć, czym jest html2canvas. Ta biblioteka nie robi zdjęcia ekranu. Czyta DOM i style, a potem rysuje stronę od nowa na kanwie. Działa jak mała przeglądarka napisana w JavaScripcie i pokazuje tylko to, co sama potrafi narysować.

Oryginalny html2canvas nie radzi sobie z nowymi funkcjami kolorów, takimi jak oklch(), z których korzysta np. Tailwind 4. Używamy forka html2canvas-pro, który je obsługuje. Elementy iframe, canvas i obrazy z obcych domen bez nagłówków CORS bywają puste, a film z YouTube to na zrzucie zwykle pusty prostokąt. Z poziomu strony tego nie naprawisz.

Drugi problem to prywatność. Na ekranie może być hasło albo numer karty. Zanim biblioteka narysuje stronę, w jej kopii (onclone) chowamy pola haseł, pola kart i wszystko z atrybutem data-asystent-private. Zmieniamy tylko kopię i na prawdziwej stronie nic nie miga. Sam czat pomijamy przez ignoreElements. Reszta ekranu trafia na obraz, dlatego nic nie odchodzi bez podglądu: rozmówca widzi zrzut, zaznacza mazakiem, o co mu chodzi, i sam decyduje o wysłaniu.

Obraz wraca do ramki przez postMessage jako ArrayBuffer przekazany na liście transfer, czyli bez kopiowania. Okno czatu daje na to 20 sekund. Jeśli skrypt na stronie się wywróci, rozmówca dostanie komunikat o błędzie zamiast wiecznego „Robię zrzut ekranu”.

Na czas zaznaczania okno idzie na pełny ekran, bo przy 384 pikselach szerokości trudno w coś trafić. Ślad mazaka zapisujemy jako ułamki szerokości i wysokości obrazu. Ten sam ślad pasuje wtedy do kanwy na ekranie i do pliku po zmniejszeniu, a rozmówca może wrócić do miniatury i poprawić zaznaczenie. getCoalescedEvents() oddaje punkty zebrane przez przeglądarkę między klatkami, dzięki czemu szybki ruch ręki nie zamienia się w łamaną. setPointerCapture pilnuje, żeby ślad nie urwał się, gdy palec wyjedzie poza kanwę.

Kompresję robi przeglądarka, żeby do serwera szedł plik do 1 MB i co najwyżej Full HD. Pętla schodzi z jakością WebP od 0,85 do 0,45, a jeśli to za mało, zmniejsza obraz o 20% i zaczyna od nowa. Safari potrafi tu zaskoczyć: canvas.toBlob(cb, "image/webp") nie zgłasza błędu, tylko oddaje PNG. Trzeba sprawdzić blob.type i w razie czego przejść na JPEG. Pod obrazem rysujemy białe tło, bo przezroczystość w JPEG-u robi się czarna.

Serwer nie wierzy w nic z tego, co deklaruje przeglądarka. Typ i wymiary czyta z pierwszych bajtów pliku. Licznik zrzutów (trzy bez potwierdzonego e-maila, potem 20 na dobę) podbija atomowo, czyli jedną operacją w bazie, jeszcze przed zapisem obrazu. Dwa równoległe żądania ominęłyby limit sprawdzany zwykłym odczytem. Wgrywania plików nie ma wcale, ani w menu, ani przez schowek. Serwer nie odróżni zrzutu od obrazu wysłanego ręcznie do API i przed nadużyciem chronią tylko limity.

Każda wiadomość kosztuje, więc publiczne API trzeba chronić

Klucz czatu jest publiczny, bo widać go w kodzie strony. Każdy może pisać prosto do API, a za każdą odpowiedź modelu płaci właściciel czatu. Zabezpieczenia mamy warstwami.

Okno czatu wysyła nagłówek CSP frame-ancestors z listą domen właściciela i przeglądarka nie osadzi go na obcej stronie. Zapis wiadomości wymaga nagłówka Origin naszej ramki. Na obcej stronie loader pokazuje komunikat, co zrobić, ale blokadą jest sam nagłówek. Skrypt na serwerze podrobi Origin bez trudu, więc to dopiero pierwsza warstwa.

Pierwsza wiadomość z nowego urządzenia wymaga domyślnie dowodu pracy ALTCHA: przeglądarka liczy w Web Workerze zadanie, które łatwo sprawdzić, a drogo wykonać masowo. Liczenie rusza, gdy rozmówca pisze, więc przy wysyłce nie czeka i nie klika obrazków z hydrantami. Dalej idą limity na adres IP i urządzenie oraz limit wiadomości bez potwierdzonego e-maila. Powtórki i zalew liczą się jako przewinienia, a po kilku czat blokuje rozmowę.

Zostaje prawo. Od 2 sierpnia 2026 r. art. 50 AI Act wymaga, żeby człowiek wiedział, że rozmawia z AI. Nasz asystent ma imię, dlatego nagłówek okna ma też etykietę „Wirtualny asystent”. Dymek przy niej mówi o AI i prowadzi do polityki prywatności, a każda wiadomość asystenta ma w HTML atrybut data-ai-generated="true". Treść rozmowy idzie do dostawcy modelu i musi o tym mówić polityka strony. Przykład znajdziesz w naszej polityce prywatności.

Test do zrobienia dziś

Jeśli masz czat na stronie albo właśnie go piszesz, sprawdź jedną rzecz. Wpisz długą wiadomość, nie wysyłaj jej i kliknij link do innej podstrony. Potem wyślij pytanie i przejdź dalej, zanim skończy się odpowiedź. Jeśli tekst albo odpowiedź zniknęły, wiesz, od czego zacząć.

Kto pisze do czatu, zwykle ma konkretną sprawę i mało czasu. Gdybym miał zostawić z tego tekstu jedną zasadę, to taką: tego, co człowiek wpisał, nie wolno zgubić, ani po błędzie sieci, ani po kliknięciu w link. Resztę możesz dopisywać po kolei, tak jak my. Więcej takich rzeczy zbieramy w dziale tworzenie stron www.

Powiązane wpisy

więcej →