Markstream — strumieniowe renderowanie Markdowna w czatach AI
Renderowanie Markdowna, który wciąż się pisze, to inny problem niż renderowanie gotowego dokumentu. Markstream rozwiązuje go dla pięciu frameworków — z dwoma trybami renderowania, listą dozwolonych znaczników i właściwością kończącą strumień.
Wyświetlenie Markdowna to problem rozwiązany dwadzieścia lat temu. Wyświetlenie Markdowna, który wciąż jest pisany, to problem całkiem inny — i każdy, kto budował interfejs czatu z modelem językowym, poznał go w praktyce.
Token przychodzi za tokenem, a ty masz w ręku łańcuch znaków, który w połowie zdania kończy się na ```pyth. Naiwne rozwiązanie — sparsuj i wyrenderuj od nowa przy każdym tokenie — daje trzy objawy, które użytkownik natychmiast zauważa. Treść migocze, bo drzewo jest przebudowywane. Układ skacze, bo niedomknięty blok kodu raz jest blokiem, raz akapitem. A przewijanie walczy z użytkownikiem, bo strona rośnie w trakcie czytania.
Markstream jest biblioteką napisaną wprost do tego zadania, i to dla pięciu frameworków jednocześnie.
Stan projektu
Zacznę od porządku z nazwą, bo się zmieniła: projekt startował jako vue-markdown-render, a dziś repozytorium nazywa się Simon-He95/markstream-vue. Stary adres przekierowuje, a stara paczka jest na npm zachowana (wersja 2.3.1 z czerwca 2026).
3004 gwiazdki, 182 forki, licencja MIT, start w maju 2025 — projekt ma niespełna półtora roku. Utrzymanie jest przy tym intensywne: commity z dnia powstania tego wpisu, a wersja 2.0.11 została opublikowana dzień wcześniej. Otwartych zgłoszeń jest zero, co przy trzech tysiącach gwiazdek jest liczbą, jakiej w tej serii nie widziałem.
Co ważniejsze przy bibliotece — sprawdziłem kanał dystrybucji, nie tylko commity. Wszystkie paczki rodziny mają na npm wersję 2.0.11 z tą samą datą, 9 września 2026. Praca jest więc wydawana, nie tylko scalana.
Osiem paczek, jedna wersja
Markstream nie jest biblioteką dla Vue, choć od tego się zaczęło. To rodzina renderujących paczek wydawanych równolegle:
markstream-vue— Vue 3, Nuxt, VitePress.markstream-react— React, Next.js, Remix.markstream-svelte— Svelte 5.markstream-angular— Angular w wariancie samodzielnych komponentów.markstream-vue2— Vue 2.6 i 2.7, dla projektów, które nie przeszły migracji.markstream-octane— dla frameworka Octane.stream-markdown-parser— sam parser, niezależny od frameworka, dla dowolnej aplikacji w JavaScripcie lub TypeScripcie.markstream-core— narzędzia sterujące strumieniem, również niezależne od frameworka.
Dwie ostatnie pozycje są warte uwagi przy ocenie ryzyka. Jeśli twój framework nie jest na liście albo nie chcesz wiązać się z warstwą renderującą, możesz wziąć wyłącznie parser i sterownik strumienia, a rysowanie napisać sam. To istotna droga wyjścia — nie kupujesz całości albo niczego.
Vue 2 na tej liście też mówi coś dobrego o podejściu autora: jest to framework po zakończeniu wsparcia, a paczka dla niego istnieje i jest wydawana w tej samej wersji co pozostałe.
Kiedy tego nie używać
Autor sam stawia tę granicę i robi to lepiej, niż zrobiłbym to ja, więc ją przytoczę. README ma sekcję zatytułowaną pytaniem „dlaczego nie marked, markdown-it albo react-markdown", a odpowiedź brzmi: tych bibliotek używaj do gotowych dokumentów Markdown, a Markstreama wtedy, gdy Markdown wciąż się zmienia w trakcie, gdy użytkownik go czyta.
To jest właściwe rozgraniczenie i warto je potraktować dosłownie. Jeśli renderujesz wpisy blogowe, dokumentację albo treść z bazy — masz gotowy dokument i nie potrzebujesz niczego z tej biblioteki. Cała jej złożoność istnieje po to, by obsłużyć stan niedokończony, a za tę złożoność się płaci rozmiarem i liczbą pojęć do poznania.
Dwie właściwości, które wyjaśniają całą bibliotekę
Przeglądając zestawienie właściwości komponentu, natrafiłem na dwie, które lepiej niż jakikolwiek opis pokazują, czym ta biblioteka się zajmuje.
Pierwsza to final. Oznacza koniec strumienia, a jej działanie opisane jest tak: wyłącza parsowanie stanu pośredniego i wymusza ustalenie niedokończonych konstrukcji. Innymi słowy: dopóki strumień trwa, renderer pracuje w trybie tolerancyjnym, świadomym, że to, co widzi, może być fragmentem. Gdy przekażesz final, przechodzi w tryb ostateczny — i wszystko, co zostało niedomknięte, zostaje rozstrzygnięte na stałe. To jest dokładnie ten mechanizm, którego brak powoduje, że własne rozwiązanie migocze.
Druga to hak parsera nazwany requireClosingStrong. Rozstrzyga pytanie, które przy strumieniu pojawia się kilka razy na sekundę: czy **pogrubi bez zamykających gwiazdek ma być już pogrubione, czy jeszcze nie? Obie odpowiedzi są uzasadnione — pierwsza daje płynny efekt pisania, druga nie skacze, gdy model użyje gwiazdek w innym celu. Istnienie tego przełącznika jako osobnej opcji mówi, że autor przemyślał problem do poziomu pojedynczych znaczników składni.
Warto przy okazji odnotować, że haki parsera można — jak zaznacza dokumentacja — dopasować tak, by serwer i klient parsowały identycznie. Przy renderowaniu po stronie serwera z późniejszym przejęciem po stronie przeglądarki to warunek uniknięcia rozjazdu.
Dwa tryby renderowania i jedna liczba
Biblioteka ma dwa tryby pracy, a przełącza się między nimi jedną właściwością — i to jest rozwiązanie, które doceniam za oszczędność.
Właściwość max-live-nodes ma domyślnie wartość 220. W tym trybie działa okno wirtualne: w drzewie utrzymywana jest ograniczona liczba żywych węzłów, więc długi dokument nie obciąża przeglądarki proporcjonalnie do swojej długości. Ustawienie tej właściwości na zero przełącza w tryb przyrostowego wsadowania, przeznaczony do efektu pisania.
Wsadowanie da się przy tym dostroić czterema parametrami: rozmiarem pierwszej partii, rozmiarem kolejnych, opóźnieniem między nimi oraz — i to jest parametr, który mówi najwięcej — budżetem czasowym w milisekundach na partię. Myślenie w kategoriach budżetu na klatkę, a nie liczby elementów, jest właściwym podejściem do renderowania, które nie ma zacinać interfejsu.
Do tego komponent przyjmuje albo surowy łańcuch Markdowna, albo wcześniej sparsowane węzły. Ta druga droga pozwala przenieść parsowanie gdzie indziej — na przykład do procesu roboczego albo na serwer — i podać rendererowi gotową strukturę.
Bloki ciężkie: diagramy, wzory i kod
Najtrudniejsza część strumieniowego renderowania to elementy, których narysowanie jest kosztowne. Diagram Mermaida albo wzór w KaTeX nie da się przeliczać przy każdym tokenie.
Markstream traktuje je jako progresywne: diagramy renderują się w miarę napływania treści, nie blokując reszty strony. Obsługiwane są Mermaid, KaTeX, a także D2 oraz warstwa infografik. Strumieniowe bloki kodu z podświetlaniem różnic obsługuje osobna, opcjonalna zależność.
I tu rzecz ważna operacyjnie, którą autor wyjaśnia w odpowiedziach na częste pytania: ciężkie zależności nie są wbudowane w paczkę. Mermaid i KaTeX są zależnościami równorzędnymi, opcjonalnymi — instalujesz je tylko wtedy, gdy ich potrzebujesz, i włączasz jawnie właściwością. Jeśli wczytujesz je ze sieci dostawczej znacznikiem skryptu, biblioteka podchwyci je z obiektu globalnego. Jest nawet funkcja tworząca proces roboczy wczytujący KaTeX z sieci dostawczej, jeśli chcesz renderować wzory poza głównym wątkiem, nie pakując przy tym biblioteki do swojego wydania.
Podobnie z drobnicą: ikony rzadziej używanych języków są wydzielone do osobnej porcji ładowanej na żądanie, a jeśli chcesz uniknąć zastępczej ikony przy pierwszym trafieniu, jest funkcja pozwalająca wczytać je w czasie bezczynności aplikacji. To poziom dbałości o rozmiar wydania, jakiego się przy takich bibliotekach nie spotyka.
Bloki rozumowania i lista dozwolonych znaczników
Ta część jest specyficzna dla czatów z modelami i dlatego najciekawsza — a przy okazji dotyka bezpieczeństwa.
Właściwość custom-html-tags rozszerza listę dozwolonych znaczników HTML w strumieniu, a przykład podany w dokumentacji jest wymowny: ['thinking']. Chodzi więc o bloki rozumowania, które modele wypuszczają w osobnych znacznikach — i o możliwość podstawienia pod nie własnego komponentu, na przykład zwijanego panelu.
Mechanizm jest przemyślany: zadeklarowane znaczniki własne trafiają do drzewa jako osobne węzły, przy czym zachowują surową treść znacznika, a jednocześnie udostępniają wariant wyrenderowany jako Markdown. Możesz więc pokazać rozumowanie jako sformatowany tekst albo dostać się do jego oryginalnej postaci.
Do rejestrowania komponentów służy osobna funkcja, przyjmująca opcjonalny identyfikator zakresu — dzięki czemu różne miejsca aplikacji mogą mieć różne odwzorowania. Obok znaczników działa też mechanizm zastępników, w którym w Markdownie umieszcza się wzorzec w podwójnych nawiasach kwadratowych, a biblioteka podstawia pod niego twój komponent.
Kluczowe jest jednak samo istnienie listy dozwolonych znaczników, którą trzeba jawnie rozszerzyć. Treść generowana przez model językowy jest treścią niezaufaną — model można namówić na wypisanie znacznika skryptu tak samo, jak można go namówić na cokolwiek innego. Domyślna lista dozwolonych oznacza, że biblioteka nie wstawia w twoją stronę wszystkiego, co dostanie, a rozszerzenie tej listy jest twoją świadomą decyzją. To właściwa domyślna postawa i warto ją docenić, bo alternatywą jest wstawianie surowego HTML-a z modelu wprost do drzewa dokumentu.
Dyscyplina wersji
Przy bibliotece o półtorarocznym stażu spodziewałbym się bałaganu wydawniczego, a jest odwrotnie — i to jest rzecz, którą przy wdrożeniu firmowym liczy się najbardziej.
Linia 2.x jest publikowana pod znacznikiem latest. Utrzymywana linia 1.x pozostaje dostępna pod osobnym znacznikiem legacy, a jej wydania przedpremierowe pod legacy-next. Do przejścia z jednej na drugą jest osobny przewodnik migracji.
Wersja 2.0 była przy tym zmianą łamiącą zgodność i autor mówi wprost, co usunęła: dwa środowiska uruchomieniowe bloków kodu, w tym oparte o edytor Monaco. Zamiast nich jest opcjonalna zależność dla wzbogaconych bloków kodu. Dla kogoś, kto stoi na 1.x i nie ma czasu na migrację, istnienie znacznika legacy oznacza, że nie musi jej robić dzisiaj.
Dokumentacja wymienia też jawnie, co należy do stabilnej powierzchni API: główny komponent, renderowanie treści strumieniowej, renderowanie wcześniej sparsowanych węzłów, bezpieczna polityka HTML, opcjonalne integracje diagramów i wzorów, wzbogacone bloki kodu, współpraca z wirtualnym przewijaniem, eksporty CSS oraz warianty importu dla renderowania po stronie serwera w Vite, Nuxcie i VitePressie. Wyliczenie, co jest stabilne, jest zobowiązaniem — i przy wersji 2.0.11 warto wiedzieć, że taka lista istnieje.
Osobno warto odnotować, że renderery dla frameworków innych niż Vue są opisane jako dostępne i czynnie rozwijane, z zaleceniem sprawdzenia dojrzałości API na stronie każdej paczki. To uczciwe rozgraniczenie: wersja dla Vue jest tą najdojrzalszą, bo od niej się zaczęło.
Dowody wydajności
Ostatnia rzecz warta wyróżnienia, bo przy bibliotekach obiecujących wydajność rzadko się ją spotyka. Repozytorium zawiera polecenie uruchamiające zestaw testów porównawczych, które generuje raport, oraz osobny dokument z listą kontrolną gotowości wydania w formie „idziemy albo nie idziemy".
Praktycznie znaczy to, że twierdzenia o wydajności można sprawdzić u siebie, na swoim sprzęcie i na swoich danych, zamiast przyjmować liczby z README. Przy komponencie, który ma renderować bez zacięć w interfejsie czatu, to jedyny sensowny sposób podejmowania decyzji.
Pułapki w jednym miejscu
- Nazwa i adres się zmieniły — projekt to dziś
markstream-vue, nievue-markdown-render. Stara paczka wciąż jest na npm i można wziąć ją przez pomyłkę. - Do gotowych dokumentów użyj czegoś prostszego. Autor sam odsyła do
markedimarkdown-it. - Pamiętaj o właściwości
final. Bez niej niedokończone konstrukcje nigdy nie zostaną rozstrzygnięte. - Haki parsera muszą być identyczne na serwerze i kliencie, inaczej renderowanie po stronie serwera rozjedzie się z przejęciem w przeglądarce.
- Mermaid i KaTeX to zależności opcjonalne — trzeba je zainstalować i jawnie włączyć, inaczej po prostu się nie wyrenderują.
- Lista dozwolonych znaczników HTML jest domyślna i wąska. Rozszerzając ją, wpuszczasz treść z modelu do drzewa dokumentu — rób to świadomie.
- Wersja 2.0 usunęła dwa środowiska bloków kodu. Przy migracji z 1.x przeczytaj przewodnik; linia 1.x żyje pod znacznikiem
legacy. - Renderery inne niż dla Vue są młodsze. Sprawdź dojrzałość API i znane ograniczenia na stronie swojej paczki.
- Projekt ma półtora roku i jednego autora. Tempo jest imponujące, ale to jedna osoba.
Podsumowanie
Markstream rozwiązuje problem, który wygląda na wąski, a dotyczy dziś praktycznie każdego, kto buduje interfejs rozmowy z modelem. I rozwiązuje go z rzetelnością, jakiej się przy półtorarocznym projekcie nie oczekuje: z zadeklarowaną stabilną powierzchnią API, utrzymywaną poprzednią linią wersji, testami porównawczymi do samodzielnego uruchomienia i przewodnikami migracji.
- Pięć frameworków plus dwie paczki niezależne od frameworka — parser i sterownik strumienia można wziąć osobno i renderować po swojemu.
- Dwa tryby renderowania przełączane jedną liczbą — okno wirtualne dla długich dokumentów, przyrostowe wsadowanie dla efektu pisania.
- Właściwość
finali haki parsera, które rozstrzygają, co zrobić z Markdownem jeszcze niedokończonym. - Progresywne diagramy i wzory jako zależności opcjonalne, niewbudowane w rozmiar wydania.
- Obsługa bloków rozumowania przez rozszerzalną listę dozwolonych znaczników i własne komponenty.
- Wszystkie paczki wydawane równolegle — jedna wersja, jedna data, bez rozjazdu między frameworkami.
Projekt jest wydany na licencji MIT — najbardziej permisywnej z popularnych, bez obowiązku otwierania własnego kodu i bez ograniczeń komercyjnych. Przy bibliotece frontendowej, która trafi do twojego wydania i pojedzie do przeglądarki użytkownika, to właściwa licencja i jedna rzecz mniej do sprawdzenia. Warto natomiast pamiętać o tym, co MIT z definicji nie daje: gwarancji ani wsparcia. Przy jednym autorze, choćby pracującym w tempie widocznym w historii commitów, jest to parametr, który należy wpisać w decyzję — a paczki niezależne od frameworka są tu dobrą polisą, bo pozwalają zachować parser i napisać własną warstwę renderującą, gdyby kiedykolwiek zaszła potrzeba.