astro-split-build: budowanie dużej wielojęzycznej strony Astro na hostingu współdzielonym

· 6 min czytania

Pakiet npm open source, który buduje wielojęzyczną stronę Astro po jednym języku naraz, scala buildy w jeden dist/ i sprawdza wszystkie strony w jednym przebiegu, zanim zepsuty bundle, link albo hreflang trafi na produkcję.

Pakiet npm open source, który buduje wielojęzyczną stronę Astro po jednym języku naraz, scala buildy w jeden dist/ i sprawdza wszystkie strony w jednym przebiegu, zanim zepsuty bundle, link albo hreflang trafi na produkcję. Pakiet npm open source, który buduje wielojęzyczną stronę Astro po jednym języku naraz, scala buildy w jeden dist/ i sprawdza wszystkie strony w jednym przebiegu, zanim zepsuty bundle, link albo hreflang trafi na produkcję.

W skrócie

Problem
Katalog części VW Polo 6R to statyczna strona Astro w trzech językach, budowana na hostingu współdzielonym z limitem procesów i wątków na konto. Jeden build wszystkich języków się nie mieścił, trzy buildy naraz też nie.
Co powstało
Narzędzie, które buduje jeden język naraz, scala buildy w jeden dist/ i sprawdza scalone strony w jednym przebiegu: bundle, linki wewnętrzne, canonicale, hreflang i własne testy.
Trudność
Najtrudniejsze okazało się scalanie: część bundli istnieje tylko w buildzie jednego języka, system plików gubi pliki w trakcie kopiowania, a linki między językami da się sprawdzić dopiero wtedy, gdy wszystkie języki są w jednym miejscu.
Moja rola
Autor i maintainer: rdzeń buildu w katalogu, wydzielenie go do publicznego repozytorium, testy, przykładowa strona, CI i wydania na npm.
Efekt
Każde wydanie katalogu (8617 stron) jest budowane i sprawdzane przez pakiet, zanim trafi na produkcję. Testy po scaleniu skróciły się z 26,2 s i 2,9 GB pamięci do niecałych 14 s i 118 MB. Licencja MIT, npm i -D astro-split-build.

Katalog części do VW Polo 6R to jedna strona Astro po angielsku, polsku i niemiecku. Wydanie, które zmierzyłem pod koniec października 2026, ma 8617 stron i 2,3 GB HTML-a. Buduje się na samym serwerze produkcyjnym, na hostingu współdzielonym w Hostido, i to ten serwer dyktuje, jak build ma działać.

Piszę tu o tym, jak ten katalog się buduje: co się psuło, co build robi teraz i jak ten kod stał się publicznym pakietem npm, który każdy może zainstalować.

Dlaczego jeden build to za dużo

Hosting współdzielony zamyka każde konto w CloudLinux LVE, które ogranicza pamięć oraz liczbę procesów i wątków na konto. Jeden astro build wszystkich języków kończył się brakiem pamięci albo błędem EMFILE: too many open files. Równoległy build trzech języków, który też próbowałem, skończył się tak samo, bo trzy buildy naraz to trzy razy więcej pamięci i otwartych plików.

Limit wątków wychodzi też w nieoczywistych miejscach. Rolldown, bundler Vite, dobiera pulę wątków do liczby rdzeni, a na tym serwerze jest ich 32. Konto odmawia wątków i build pada, zanim wyrenderuje pierwszą stronę. Dlatego katalog ogranicza tę pulę do ośmiu wątków, a buildy uruchamia wyłącznie jeden po drugim.

Jeden język naraz i co się przez to psuje

Sam podział buildu jest prosty. Każde uruchomienie dostaje BUILD_LOCALE, a główna trasa renderuje tylko strony tego języka. Trzy małe buildy mieszczą się tam, gdzie jeden duży się nie mieścił.

Kłopoty zaczynają się, gdy trzy wyniki mają się stać jedną stroną. Naiwne scalanie ma dwie pułapki:

  1. Zgubione bundle. Każdy build generuje własne pliki JS i CSS z hashem treści w nazwie. Jeśli z polskiego buildu zostawisz tylko pl/, każdy fragment, który wyszedł tam z innym hashem, znika. Strona dalej odpowiada kodem 200, ale jej interaktywna wyspa nigdy się nie ładuje. Zdarzyło się to na produkcji.
  2. System plików, który nie nadąża. Na współdzielonym dysku w stylu NFS rekurencyjne usuwanie wpada w wyścig (ENOTEMPTY), a duża kopia nie zawsze jest widoczna przy następnym odczycie katalogu (ENOENT w połowie pętli).

Dlatego scalanie kopiuje angielski build w całości do katalogu głównego, z pozostałych bierze tylko /pl/ i /de/, a wspólny katalog bundli _astro/ łączy ze wszystkich trzech. Nazwy bundli to hashe treści, więc dwa pliki o tej samej nazwie mają te same bajty i nadpisanie jest bezpieczne. Każde kopiowanie i usuwanie jest ponawiane, a po zapisie build chwilę czeka, zanim następny krok odczyta to, co zapisano.

Od trzech buildów do jednego wydania
    1. astro build: en

      kopiowany w całości, jako katalog główny

    2. astro build: pl

      zostaje z niego tylko pl/

    3. astro build: de

      zostaje z niego tylko de/

  1. Scalenie w jeden dist/

    en w katalogu głównym, pod nim /pl/ i /de/, a każdy bundle _astro/ ze wszystkich trzech buildów.

  2. Test każdej strony, raz

    • odwołania do bundli
    • linki wewnętrzne
    • canonical i hreflang, z linkiem zwrotnym
    • własne testy stron
    • test przecieków języka z katalogu
  3. Wszystkie testy przechodzą

    Przełączenie wydania

    Nowy build trafia na produkcję.

    Któryś test nie przechodzi

    Zostaje poprzednie wydanie

    Nic nie jest przełączane, a nieudane buildy zostają na dysku do wglądu.

Dlaczego testy idą po scaleniu

Każdy test celowo czyta scalony dist/, a nie trzy osobne buildy. Przełącznik języka i alternatywy hreflang linkują między językami: strona angielska wskazuje swoją polską i niemiecką wersję, a te muszą wskazywać z powrotem. Sam polski build nie wie, czy /de/… istnieje, bo go nie budował, więc sprawdzić to można dopiero na scalonym wyniku.

Po scaleniu pakiet czyta więc strony i przerywa build, jeśli:

  • strona odwołuje się do bundla w _astro/, którego nie ma w scalonym wyniku,
  • link wewnętrzny, canonical albo alternatywa hreflang prowadzi do strony, która nie powstała (link może iść przez przekierowanie z serwera, o ile kończy się na zbudowanej stronie),
  • alternatywa nie linkuje z powrotem albo jedna strona podaje dla jednego języka dwa adresy,
  • któryś z własnych testów zgłosi problem. Katalog dokłada tu swój: żadna strona nie może zawierać tekstów interfejsu z innego języka, bo kiedyś polska strona wyszła z niemieckim tytułem.

Build kończy się wtedy niezerowym kodem, a skrypt wdrożeniowy katalogu przełącza symlink wydania dopiero po zerze. Zepsuty build nigdy nie zastępuje działającego: poprzednie wydanie zostaje na produkcji, a zapisane buildy czekają na dysku do analizy.

Jeden przebieg zamiast trzech

Na początku każdy test był osobnym skryptem i każdy sam listował dist/ i czytał wszystkie 8617 stron: 2,3 GB HTML-a, trzy razy. Test linków dodatkowo trzymał w pamięci każdą stronę aż do końca. Zapamiętywał znalezione wartości href i hreflang, a V8 przechowuje podciąg jako widok na tekst, z którego pochodzi, więc każda krótka wartość trzymała przy życiu całą swoją stronę.

Poprawka miała dwie części. Wszystkie testy działają teraz jako callbacki w jednym przejściu: katalog jest listowany raz, a każda strona czytana raz, niezależnie od liczby testów. Wartości, które test zachowuje, są najpierw kopiowane ze strony (detach() w pakiecie), więc stronę można zwolnić, gdy tylko przejście pójdzie dalej.

Obie wersje zmierzyłem przez SSH na produkcyjnym wydaniu i obie znalazły dokładnie to samo (122 958 odwołań do bundli, 2 833 064 linki, zero problemów z hreflang):

Testy po scaleniu: czas
Testy po scaleniu: czasCzas testów po scaleniu na 8617 stronach. Trzy osobne skrypty zajmowały 26,2 sekundy, jeden przebieg około 13,6 sekundy.Trzy osobne testy · przed: 3 skrypty26,2  sJeden przebieg, trzy testy · po: jeden przebieg13,6  s

Przed: 5,0 + 12,6 + 8,6 s. Po: od 13,2 do 13,7 s w kolejnych uruchomieniach.

Testy po scaleniu: czas
PozycjaGrupaWartość
Trzy osobne testyprzed: 3 skrypty26,2  s
Jeden przebieg, trzy testypo: jeden przebieg13,6  s
Testy po scaleniu: szczyt pamięci
Testy po scaleniu: szczyt pamięciSzczytowe zużycie pamięci przez testy po scaleniu. Sam test linków dochodził do 2,9 GB, jeden przebieg zatrzymuje się na 118 MB.Trzy osobne testy · przed: 3 skrypty2900  MBJeden przebieg, trzy testy · po: jeden przebieg118  MB

Szczytowe RSS. Przed: sam test linków dochodził do 2,9 GB. Po: żywa sterta nie przekracza 15 MB.

Testy po scaleniu: szczyt pamięci
PozycjaGrupaWartość
Trzy osobne testyprzed: 3 skrypty2900  MB
Jeden przebieg, trzy testypo: jeden przebieg118  MB

Na serwerze, na którym brakuje przede wszystkim pamięci, drugi wykres znaczy więcej niż pierwszy.

Z folderu w katalogu do pakietu npm

Rdzeń buildu powstał jako folder w repozytorium katalogu, z testami, ale dostępny tylko przez ścieżkę względną. Nic w nim nie dotyczyło części samochodowych, więc epik spokospace/catalog.polo.blue#1047 wyniósł go na zewnątrz:

  • Dostał osobne repozytorium, spokospace/astro-split-build, wydzielone z katalogu razem z historią. Ma licencję MIT, wymaga Node 20 lub nowszego i nie ma żadnych zależności.
  • examples/astro-i18n to mała strona Astro w trzech językach z własnym testem strony. CI uruchamia testy na Node 20, 22 i 24 i buduje przykład dwa razy, w katalogu głównym i pod ścieżką bazową (/docs), bo base zmienia sposób rozwiązywania każdego linku i odwołania do bundla.
  • semantic-release publikuje go z GitHub Actions przez npm trusted publishing (OIDC). Nie ma więc sekretu NPM_TOKEN, który mógłby wyciec albo wygasnąć, a każda wersja ma atestację pochodzenia (provenance), która wiąże ją z commitem i workflow, z którego powstała.
  • Wersja 0.1.0 wyszła 30 października 2026, a wersja 0.2.0 ukazała się 1 listopada, z deklaracjami TypeScript generowanymi z JSDoc. Pakiet zostaje czystym JavaScriptem, ale konfiguracja, wyniki i testy stron mają typy.
  • Katalog instaluje pakiet z npm jak każdą inną zależność, przypięty do dokładnej wersji. W jego repozytorium zostaje tylko to, co należy do katalogu: lista języków, limit wątków serwera, test przecieków języka, obrazki Open Graph i Pagefind.

Do użycia wystarczy jeden plik konfiguracyjny i jedno polecenie:

js
// split-build.config.js
/** @type {import('astro-split-build').SplitBuildOptions} */
export default {
  targets: [
    { name: 'en', root: true },
    { name: 'pl', subdir: 'pl' },
    { name: 'de', subdir: 'de' },
  ],
  command: 'astro build',
  env: (target) => ({ BUILD_LOCALE: target.name }),
  sharedDirs: ['_astro'],
  verifyAssets: { dir: '_astro' },
  verifyLinks: { site: 'https://example.com' },
};
bash
npx astro-split-build --config split-build.config.js

Kiedy się przydaje, a kiedy nie

Jeśli jeden astro build wszystkich języków mieści się w pamięci, zostań przy nim: podział dokłada scalanie, którego nie potrzebujesz. Pakiet jest dla stron, które się nie mieszczą: tysiące podstron w kilku językach, na hostingu współdzielonym, małym VPS-ie albo na Windowsie.

Same testy przydają się jednak i bez podziału. verifyAssetRefs, verifyInternalLinks i scanPages działają na dowolnym gotowym buildzie statycznym, więc strona budowana jednym poleceniem też może zatrzymać wdrożenie na brakującym bundlu albo na hreflang bez linku zwrotnego.

Moja rola

Napisałem rdzeń buildu w katalogu, znalazłem i naprawiłem awarie, przed którymi chroni (zgubione bundle, wyścigi systemu plików, pamięć trzymaną przez podciągi), zmierzyłem jeden przebieg na produkcji i wydzieliłem całość do publicznego pakietu: repozytorium, README, przykładowa strona, testy, CI i proces wydań.

Technologie

Astro

Generator strony. Pakiet zakłada routing i18n Astro z domyślnym językiem w katalogu głównym i pozostałymi pod własnym prefiksem.

Node.js, bez zależności

Czysty JavaScript na Node 20+, tylko biblioteka standardowa: procesy potomne do buildów, fs do scalania i przeglądu stron.

Deklaracje TypeScript

Generowane z JSDoc przy publikacji, więc konfiguracja i własne testy stron mają typy w edytorze.

semantic-release i npm provenance

Conventional commits wyznaczają wersję, a trusted publishing przez OIDC wydaje ją z atestacją pochodzenia.

Open source (MIT)

Publiczny na GitHubie, z testami na Node 20, 22 i 24 i przykładową stroną Astro budowaną w CI.

Powrót do portfolio