Wyspy, które można ponownie uruchomić na Bun: Lekcje projektowania z ramy dla ceramiki
Jak framework z naciskiem na serwer traktuje HTML jako standard, ogranicza działanie JavaScript do oddzielnych obszarów, a także zajmuje się statycznym eksportem, błędami, CSP oraz wdrażaniem.
Większość stron w Internecie składa się z treści z kilkoma interaktywnymi elementami dodanymi na górze, jednak dominujący model rozwoju wysyła całą stronę do przeglądarki jako aplikację JavaScript. Stoneware, młody framework typu TypeScript o otwartym kodzie źródłowym stworzony na bazie Bun, wychodzi z przeciwnej założenia: HTML jest tym, co przeglądarka otrzymuje domyślnie, a komponent musi wyraźnie się na to zgodzić, zanim zostanie dla niego wysłany jakikolwiek JavaScript. Przegląd jego projektu to przydatny sposób na zrozumienie koncepcji „wysp”, możliwości ponownego uruchomienia, statycznego eksportu oraz mniej oczywistych aspektów inżynierii frameworków, takich jak komunikaty o błędach, zasady bezpieczeństwa i pakietowanie aplikacji przed wdrożeniem. Pod koniec powinieneś być w stanie ocenić, kiedy architektura oparta na serwerze i „wyspach” nadaje się do twojego projektu, oraz na jakie pułapki należy uważać podczas jej tworzenia lub wdrażania.
Dlaczego strona z treścią nie powinna stać się aplikacją
Załóżmy typową stronę produktu. Zawiera ona tytuł, zdjęcie, opis, listę specyfikacji, cenę, powiązane produkty, recenzje oraz elementy nawigacyjne. Spośród tego wszystkiego prawdopodobnie tylko dwie rzeczy faktycznie reagują na użytkownika: menu mobilne oraz przycisk „Dodaj do koszyka”. Wszystko inne to treść statyczna, której serwer już potrafi wygenerować.
Podejście oparte na renderowaniu przez klienta lub pełnym załadunku danych nadal wymaga od przeglądarki pobrania, zinterpretowania i wykonania kodu reprezentującego całą stronę, aby te dwa elementy mogły funkcjonować. Pytanie z perspektywy serwera jest proste: a co, jeśli przeglądarka otrzymałaby kod tylko dla tych części, które go potrzebują?
Model mentalny: HTML plus wyspy
Architektura dzieli stronę na dwa rodzaje obszarów. Większa część to HTML generowany na serwerze. Elementy interaktywne stają się „wyspami” – małymi, samodzielnymi regionami, które zawierają własny kod JavaScript. Oba rodzaje elementów znajdują się w tym samym dokumencie w przeglądarce.
Web page
│
┌───────────┴───────────┐
│ │
HTML Islands
│ │
Server rendered JavaScript
│ │
└───────────┬───────────┘
│
Browser
Głównym skutkiem jest to, że interaktywność nie zmusza już do wysyłania całego aplikacji. Uczynienie jakiegoś widgetu interaktywnym oznacza konieczność przesłania tylko kodu tego widgetu, a nie całej strony.
Budowanie na Bun zamiast kompletowania zestawu narzędzi
Wybór Bun nie oznacza, że Node.js jest przestarzały. Node posiada ogromny ekosystem i służy do obsługi dużej części aplikacji JavaScript w produkcji; nadal jest doskonałym środowiskiem wykonawczym. Interesujące jest to, co zmienia się, gdy framework jest projektowany wokół środowiska wykonawczego, które już zawiera narzędzia niezbędne w większości projektów.
Bun dostarcza środowisko wykonawcze JavaScript wraz z menedżerem pakietów, narzędziem do łączenia plików oraz narzędziem do testowania. Dla twórców frameworków oznacza to eliminację wielu dodatkowych elementów. Zamiast budować warstwy – najpierw framework na Node, potem menedżer pakietów, następnie oddzielne narzędzie do łączenia plików i osobne narzędzie do testowania – projekt może traktować je wszystkie jako jedną spójną bazę.
Bun
│
┌────────────┼────────────┐
│ │ │
Runtime Tooling Testing
│ │ │
└────────────┼────────────┘
↓
Stoneware
Warto jasno rozróżniać role: Bun to platforma, a Stoneware to framework działający na niej. Jeśli chcesz szerszego porównania samych środowisk wykonawczych, zapoznaj się z porównaniem Node.js, Deno i Bun.
HTML na pierwszym miejscu, traktowane poważnie
Renderowanie po stronie serwera istnieje od dawna, więc zwrot „renderowanie HTML na serwerze” nie wydaje się niczym wyjątkowym. Silniejszą zasadą Stoneware jest to, że serwer powinien wygenerować naprawdę przydatny HTML, zanim przeglądarka będzie musiała cokolwiek rozumieć na temat aplikacji.
Weźmy komponent, który wyświetla produkt wraz z tytułem i ceną.
<ProductCard
title="MacBook Pro"
price={1999}
/>
W tym komponencie nie ma nic, co wymagałoby od przeglądarki przechowywania jego modelu w JavaScript. Serwer może zamienić go na zwykły kod markup:
<div class="product-card">
<h2>MacBook Pro</h2>
<span>$1999</span>
</div>
To wyjście jest już kompletnie gotowe. Ludzie mogą je czytać, roboty wyszukiwarek mogą je indeksować, a przeglądarka może je natychmiast wyświetlić. W dostarczaniu samego treści nie bierze udziału żaden skrypt.
Cięte decyzje dotyczące JavaScript
Teraz dodajmy przycisk koszyka. W odróżnieniu od karty produktu musi reagować na kliknięcia, aktualizować stan i prawdopodobnie komunikować się z API.
<AddToCart product={product} />
To komponent jest miejscem, w którym uzasadniane jest zachowanie po stronie klienta, dlatego staje się „wyspą”. Reszta strony pozostaje w formie HTML, a ostateczna strona wygląda tak:
Product page
│
├── Product title HTML
├── Product description HTML
├── Product image HTML
├── Product specifications HTML
│
└── Add to cart JavaScript island
Strona nie jest „bez JavaScripta”. Jest skryptowana selektywnie, a wybór dokonywany jest dla poszczególnych komponentów, a nie całej strony. Ta różnica ma znaczenie, ponieważ pozwala zachować prostotę domyślnych rozwiązań i sprawia, że każdy fragment kodu staje się widocznym, świadomym wyborem.
Gdzie znajduje się granica „wyspy”
Wyspa oznacza granicę pomiędzy treścią renderowaną na serwerze a funkcjonalnościami działającymi po stronie klienta. Strona dokumentacyjna wyraźnie pokazuje ten wzorzec. Treść artykułu, nagłówki, przykłady kodu, obrazy, linki, elementy nawigacyjne oraz stopka to wszystko treść. Tylko kilka funkcji jest naprawdę interaktywnych: wyszukiwarka, przełącznik tematów, przyciski kopiowania do schowka kodu oraz ewentualnie drzewo nawigacyjne, które można rozwijać.
Documentation page
│
├── Article ─────────────── SSR
├── Code blocks ─────────── SSR
├── Images ──────────────── SSR
├── Navigation ──────────── SSR
│
├── Search ──────────────── Island
├── Theme switcher ──────── Island
└── Copy button ─────────── Island
Taki rodzaj strony, składający się głównie z treści z kilkoma interaktywnymi obszarami, jest dokładnie tym, do czego zostało zaprojektowane to framework.
Hydryacja versus możliwość kontynuacji
Sprawiedliwą zastrzeżeniem jest stwierdzenie, że to wszystko to po prostu SSR. Częściowo tak jest – serwer renderuje HTML. Różnica polega na tym, co dzieje się po dotarciu tego HTML.
W przypadku klasycznej hydryacji sekwencja wygląda mniej więcej tak:
- serwer wysyła HTML;
- przeglądarka pobiera JavaScript aplikacji;
Przeglądarka ostatecznie odbudowuje aplikację, której wynik już otrzymała. Z punktu widzenia użytkownika jest to czysty nadmiar pracy – piksele były już na ekranie.
Zamiast tego ceramika jest projektowana wokół możliwości wznowienia pracy poszczególnych obszarów. Serwer wysyła HTML wraz z danymi stanu potrzebnymi do działania tych interaktywnych obszarów, a przeglądarka kontynuuje pracę tam, gdzie serwer przerwał, zamiast ponownie wykonywać stronę, aby odzyskać ten stan. Celem jest zapobieganie temu, by małe interaktywne obszary zmuszały do pełnej odbudowy strony.
Inny cel optymalizacji
Koncepcja wznowialności zmienia sposób patrzenia na kwestię wydajności. Zamiast zastanawiać się, jak przyspieszyć proces hydratacji wszystkiego, pytamy o to, ile pracy przeglądarki można całkowicie uniknąć. Struktura danych zmienia się z „HTML wraz ze wszystkim JavaScriptem aplikacji plus jedna faza hydratacji” na „HTML wraz tylko z kodem niezbędnym do interakcji, przywracanie stanu zrenderowanego na serwerze”. Dla stron o dużej ilości treści jest to często znacznie większy atut niż jakakolwiek optymalizacja hydratacji.
Jeśli pracujesz z React, ten sam cel leży u podstaw komponentów serwerowych; architektura stojąca za renderowaniem bez pliku bundle opisuje ten podejście i przedstawia przydatne porównanie.
Podejście „serwer na pierwszym miejscu” to nie tylko serwer
Żadne z tych argumentów nie przemawia przeciwko aplikacjom po stronie klienta. Edytory współpracy, narzędzia do projektowania, gry w przeglądarce oraz zaawansowane interaktywne aplikacje do obsługi danych mają zupełnie inne wymagania, a większość ich komponentów jest z natury interaktywna. Architektura ta skupia się na drugim końcu spektrum: stronach, gdzie większość treści jest naturalnie renderowana na serwerze, a interakcja stanowi wyjątek.
Eksport statyczny wynika z tej samej koncepcji
Jeśli HTML jest standardem, naturalnym następnym pytaniem jest to, dlaczego w ogóle należy uruchamiać serwer dla stron, które nigdy się nie zmieniają przy każdym żądaniu. Stoneware może eksportować aplikację jako pliki statyczne i przekazać je CDN.
Stoneware application
│
▼
export
│
▼
dist/
│
▼
CDN
Dokumentację, strony marketingowe oraz katalogi produktów można często przygotować z wyprzedzeniem i serwować bezpośrednio z edge. Trasy, które rzeczywiście wymagają logiki na poziomie każdej prośby, mogą pozostać renderowane na serwerze. Zaletą jest to, że przeniesienie trasy pomiędzy wersją statyczną a SSR nie wymaga zmiany modelu programowania całego aplikacji.
Trasy dynamiczne wymagają wyraźnej listy ścieżek
Eksport statyczny staje się trudniejszy, gdy tylko trasa zawiera parametr. Trasa taka jak /products/[sku] może, w zasadzie, odpowiadać nieograniczonej liczbie adresów URL, więc narzędzie eksportujące nie może odgadnąć, jakie strony należy utworzyć. Dlatego framework prosi trasę o ich wyliczenie:
export function staticPaths() {
return products.map(product => ({
sku: product.sku
}));
}
Z pomocą tej listy eksporter tworzy rzeczywisty plik HTML dla każdego znanej SKU, na przykład dist/products/laptop-1/index.html. W tym momencie projekt frameworka wykracza poza prostą renderzację JSX: framework musi zrozumieć, jak trasy, dane, krok budowania oraz cel deplojacji są ze sobą powiązane. Praktycznym przypadkiem krawędziowym, który należy uwzględnić, jest sytuacja, gdy dynamiczna trasa w ogóle nie ma metody staticPaths(); framework musi albo wyraźnie poinformować o błędzie podczas eksportu, albo utrzymać tę trasę w formie renderowanej na serwerze – milczące jej pominięcie jest najgorszą opcją.
Wiadomości o błędach są częścią narzędzia renderującego
W istocie renderer bierze komponent i tworzy HTML. Trudność polega na ogromnej różnorodności elementów dziecięcych i atrybutów, z którymi musi sobie poradzić: wartości tekstowe i liczbowe, tablice oraz null, elementy i zagnieżdżone komponenty, sygnały i atrybuty, błędy, prace asynchroniczne oraz, co nieuniknione, wartości, które nie mogą zostać wyrenderowane.
Częstym błędem jest napisanie <span>{product}</span>, gdy chciano napisać <span>{product.name}</span>. Ogólne komunikaty typu „nie można wyrenderować wartości typu obiekt” prawie nic nie mówią o tym, gdzie szukać przyczyny. Stoneware natomiast opisuje konkretną wartość, która powoduje problem:
Cannot render a plain object with keys: id, title, price.
a następnie podaje ścieżkę komponentu, która do niej prowadzi:
in <span>
in <Price>
in <ProductCard>
in <Home>
Wylistowanie kluczy obiektu wskazuje na właściwość, którą prawdopodobnie mieliśmy na myśli, a ślad komponentu wskazuje dokładny plik do otwarcia. Framework ocenia się nie tylko pod kątem prawidłowego przebiegu, ale także tego, jak szybko pomaga wyjść z sytuacji problematycznych.
Gdy mikrotest ukrywa rzeczywisty koszt
Początkowo zbieranie takiego śladu komponentu oznaczało otaczanie wielu operacji renderowania blokami try/catch. Izolowany mikrotest wskazywał, że dodatkowy koszt jest zaniedbywalny. Jednak przy rzeczywistym renderowaniu strony otaczanie każdego elementu sprawiało, że proces renderowania był o około 38% droższy.
To wyjaśnienie jest znane każdemu, kto przeprowadza testy JavaScript: w małym, powtarzalnym teście kompilator optymalizujący silnika może usunąć lub przenieść dużą część pracy, którą próbujemy zmierzyć, więc uzyskana liczba odzwierciedla wersję kodu po optymalizacji.
Mikropomiary mogą wprowadzać w błąd, gdy czas wykonywania optymalizuje dokładnie to, co jest mierzone.
Rozwiązanie było strukturalne. Nakład pracy związaný z śledzeniem błędów został ograniczony do granic poszczególnych komponentów, a pojedyncze elementy wykorzystywały tańsze operacje zapisu i przywracania, aby utrzymać aktualny stan. Pomiarы typu A/B na rzeczywistych renderach nie wykazały żadnych istotnych różnic. Szersza lekcja polega na tym, że wydajność renderera zależy nie tylko od szybkości, ale także od unikania regresów podczas dodawania funkcji przyjaznych programistom, a wszelkie twierdzenia dotyczące wydajności powinny być weryfikowane na reprezentatywnych obciążeniach.
Domyślne ustawienia bezpieczeństwa i kolejność przetwarzania
Zwykła aplikacja nie powinna wymagać od programisty pamiętania o każdej z podstawowych funkcji bezpieczeństwa przed uruchomieniem w produkcji. Stoneware dostarczany jest z domyślnymi ustawieniami, takimi jak ochrona przed CSRF i obsługa Content Security Policy.
Kolejność procesowania żądań jest celowa:
- Najpierw uruchamiana jest ochrona CSRF;
- Następnie działa middleware aplikacji;
- Potem następuje dopasowywanie ścieżek i renderowanie;
- Wszystko opuszcza aplikację przez jeden punkt odpowiedzi.
Gdyby middleware aplikacji działał przed sprawdzeniem CSRF, kod użytkownika mógłby trafić na ścieżkę, która omija zasady bezpieczeństwa na poziomie frameworka – na przykład poprzez wczesne zakończenie obsługi żądania lub jego przepisanie. Zapisanie tej kolejności w samym frameworku, zamiast jedynie jej udokumentowania i oczekiwania, że wszyscy się do niej będą trzymać, to właśnie taka zasada, którą framework powinien określić. Jeden punkt wyjścia oznacza również, że nagłówki takie jak CSP są stosowane spójnie we wszystkich odpowiedziach.
Rozszerzanie ścisłych zasad CSP bez ich wyłączania
Zastrzeżona polityka jest dobrym standardem, ale prawdziwe strony ładują narzędzia analityczne, elementy płatności, API, czcionki webowe oraz mapy. Częstym problemem jest to, że programista napotka zablokowany skrypt i całkowicie wyłączy CSP. Lepszy projekt umożliwia rozszerzanie poszczególnych dyrektyw przy jednoczesnym zachowaniu reszty standardowej polityki:
csp: {
scriptSrc: ["https://www.googletagmanager.com"],
connectSrc: ["https://www.google-analytics.com"],
imgSrc: ["https://www.google-analytics.com"],
}
Zasada polega na bezpiecznych standardach wraz z wyraźną listą dopuszczalnych elementów dla stron trzecich, określoną dla każdej dyrektywy. Projektując takie API, należy jasno określić, czy dostarczony źródło uzupełnia standardową dyrektywę, czy ją zastępuje, oraz udokumentować tę decyzję – oba podejścia są możliwe, a różnica ma konsekwencje bezpieczeństwa.
Najtrudniejsze błędy pojawiają się po procesorze renderowania
Framework nie kończy się przy rendererze. Kod musi przetrwać całą drogę: od źródła, przez kompilator i renderer, aż po proces budowania, pliki assets, wdrożenie, CDN i wreszcie przeglądarkę. Niektóre z najtrudniejszych problemów pojawiają się pod koniec tej łańcuchowej ścieżki, daleko od kodu, który większość ludzi uważa za „framework”.
Pakowanie plików island do Vercel
Wersja 0.1.8 zmieniła sposób, w jaki Stoneware wdraża aplikacje na Vercel. Dla tego celu generowane fragmenty kodu klienta są teraz włączane do pliku serwera jako dane w formacie base64, dzięki czemu stają się częścią pliku, który platforma wdraża, i są dostarczane z adresu /_stoneware/*.
Stoneware build
↓
server bundle
├── server code
├── CSS assets
└── island assets
↓
Vercel
↓
/_stoneware/*
To zachowanie jest opcjonalne i dotyczy wyłącznie celu Vercel. Rozwiązanie typu container deployment ma już pliki na dysku, więc nie ma powodu, by przechowywać drugą kopię każdego fragmentu w pliku serwera. Ogólnie rzecz biorąc, platformy hostingowe różnią się tym, co włączają do procesu rozgłoszenia, dlatego obsługa zasobów często wymaga strategii dostosowanych do konkretnego celu, a nie jednej uniwersalnej odpowiedzi.
Testy potwierdzające poprawkę
Projekt zawiera ponad 500 automatycznych testów obejmujących renderer, router, mechanizmy islands i signals, arkusze stylów oraz serwowanie zasobów, eksport statyczny, mechanizmy CSRF i CSP, cele rozgłoszenia, raportowanie błędów oraz nieprawidłowe lub wrogie dane wejściowe, takie jak próby przechodzenia przez ścieżki, pliki binarne i błędnie sformatowane ścieżki do zasobów.
Sama liczba testów nie jest kluczowa. Bardziej przydatnym kryterium jest to:
Test regresji powinien pokazać, że zawiódłby on przed wprowadzeniem poprawki.
Test, który przechodzi zarówno przed, jak i po zmianie, dokumentuje zachowanie, ale nie chroni przed powrotem błędu. Sprawdzanie, czy nowy test zawodzi w przypadku starego kodu, to tania praktyka, która sprawia, że zestaw testów staje się znacznie bardziej wiarygodny.
Wykorzystywanie agenta kodującego bez zlecania projektowania
Znaczna część Stoneware została zaimplementowana przy użyciu Claude’a jako agenta kodującego. Był on szczególnie skuteczny w badaniu rozwijającej się bazy kodu, pisaniu powtarzalnej infrastruktury, tworzeniu testów, analizowaniu błędów, refaktoryzacji, dokumentowaniu architektury oraz badaniu przypadków krawędziowych.
Tego, czego nie mógł zrobić samodzielnie, było podjęcie decyzji o tym, jaka powinna być architektura. Trudne pytania dotyczyły aspektów architektonicznych:
- Dla każdej trasy – czy SSR, czy eksport statyczny to właściwy tryb?
staticPaths()?Agent może zbadać te pytania i zaimplementować wybraną odpowiedź, ale ktoś nadal musi zakwestionować taki projekt i sprawdzić wynik.
Prawdopodobne nie jest to samo co poprawne
Agent kodujący może bardzo szybko stworzyć przekonującą implementację, a ta szybkość jest cenna. Oznacza to jednak również, że może błędnie działać szybko. Problem z zasobami w Vercel ilustruje ten błąd pętli: pierwsza poprawka skopiowała utworzone zasoby do katalogu public/, co wydawało się sensowne i przeszło lokalne testy; jednak rzeczywista implementacja pokazała, że to założenie było błędne. Druga próba polegała na zmianie samego modelu implementacji. Testy przypadków krawędziowych ujawniły następnie kolejny błąd w tej wersji.
Ten cykl to typowa praca inżyniera oprogramowania, a nie awaria sztucznej inteligencji. To, co się zmienia, to szybkość każdej iteracji, co sprawia, że weryfikacja od początku do końca w rzeczywistym środowisku docelowym staje się ważniejsza, a nie mniej istotna.
Dlaczego wybór środowiska wykonawczego ma znaczenie poza szybkością
Zredukowanie historii do stwierdzenia „Bun jest szybszy od Node” pomija sedno sprawy, a surowa szybkość i tak nie stanowi filozofii frameworka. Bardziej interesującym eksperymentem jest zaprojektowanie frameworka przy założeniu, że od samego początku dostępne są nowoczesny środowisko wykonywania oraz zintegrowany zestaw narzędzi. Połączenie w Bunie środowiska wykonywania, zarządzania pakietami, pakowania i testowania czyni go wygodną podstawą do tego rodzaju eksploracji architektonicznych.
Gdzie pasuje ta architektura
Taki podejście sprawdza się wtedy, gdy większość strony to treść, a tylko jej część jest interaktywna:
- Dokumentacja: artykuły i przykłady kodu są renderowane na serwerze; funkcje takie jak wyszukiwanie, zmiana tematu i przyciski kopiowania stanowią oddzielne elementy.
- E-commerce: szczegóły produktów, zdjęcia i treści SEO są renderowane na serwerze; koszyk zakupów i filtry to oddzielne elementy.
Gdzie prawdopodobnie nie nadaje się to narzędzie
Jeśli twoje rozwiązanie to w rzeczywistości aplikacja desktopowa działająca w przeglądarce, taka jak edytor współpracy, narzędzie graficzne, gra, interaktywny panel sterowania lub każda aplikacja, w której prawie wszystkie komponenty działają na stronie klienta, udział strony, który może pozostać w formacie HTML, jest niewielki. W takim przypadku model „wysp” dodaje ograniczenia bez większej korzyści, a architektura skupiona na stronie klienta jest prawdopodobnie lepszym rozwiązaniem. Framework może mieć wyraźną opinię, nie udając, że nadaje się do każdego rodzaju zadań.
Próba wykorzystania
W chwili pisania ceramika jest nadal w wersji 0.1.x, więc należy się spodziewać niedoskonałości i sprawdzić stan w repozytorium. Planowane ulepszenia obejmują więcej integracji, lepszą diagnostykę i ostrzeżenia dotyczące rozwoju, więcej przykładów oraz celów implementacji, lepszą dokumentację i większą liczbę zastosowań w praktyce. Aby stworzyć szkielet projektu i uruchomić serwer deweloperski:
bun create stoneware my-app
cd my-app
bun dev
Dobrym pierwszym eksperymentem jest coś małego, ale bogatego w treść: blog, strona dokumentacyjna, katalog produktów, portfolio lub strona firmowa. Podczas tworzenia zadawaj sobie pytanie, ile z treści strony faktycznie wymaga JavaScriptu.
Główne wnioski
- Traktuj HTML jako standardowy format wyjściowy, a JavaScript na stronie klienta jako opcję do włączenia dla poszczególnych komponentów; dzięki temu strona będzie miała wybrane elementy z skryptami, a nie będzie pełną aplikacją.
Literatura pokrewna
- Projektowanie API Node.js w warstwach: od grubych kontrolerów do czystej architektury — Dowiedz się, jak przeredagować API Node.js na warstwy kontrolerów, usług i dostępu do danych, aby rozwiązać problemy z skomplikowaną logiką biznesową, niejednolitymi błędami oraz trudnościami w skalowaniu.
- Wbudowane funkcje Bun 1.4, które mogą zastąpić sharp, Puppeteer, node-pty i inne — Praktyczny przegląd wbudowanych funkcji Bun 1.4 do obsługi obrazów, przeglądarek, Markdowna, zadań cron, terminala, skryptów równoległych oraz testów, wraz z informacjami o tym, jak bezpiecznie je wypróbować w rzeczywistych projektach.