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

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ę.

**Canonical:** https://spoko.space/pl/astro-split-build/  
**Language:** pl  
**Published:** 2026-11-01  
**Tags:** astro, i18n, javascript, npm, open-source, seo  
**Category:** Portfolio  
**GitHub:** https://github.com/spokospace/astro-split-build

---
<div class="text-center flex gap-4 justify-center flex-wrap">

</div>

## W skrócie {#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](https://spoko.space/pl/katalog-czesci-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 {#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 {#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) → `astro build: pl` (zostaje z niego tylko `pl/`) → `astro build: de` (zostaje z niego tylko `de/`)
2. **Scalenie w jeden dist/** — en w katalogu głównym, pod nim `/pl/` i `/de/`, a każdy bundle `_astro/` ze wszystkich trzech buildów.
3. **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

- 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 {#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 {#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**

| Pozycja | Grupa | Wartość |
| --- | --- | --- |
| Trzy osobne testy | przed: 3 skrypty | 26,2 s |
| Jeden przebieg, trzy testy | po: jeden przebieg | 13,6 s |

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

**Testy po scaleniu: szczyt pamięci**

| Pozycja | Grupa | Wartość |
| --- | --- | --- |
| Trzy osobne testy | przed: 3 skrypty | 2900 MB |
| Jeden przebieg, trzy testy | po: jeden przebieg | 118 MB |

Szczytowe RSS. Przed: sam test linków dochodził do 2,9 GB. Po: żywa sterta nie przekracza 15 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 {#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](https://github.com/spokospace/catalog.polo.blue/issues/1047) wyniósł go na zewnątrz:

- Dostał osobne repozytorium, [spokospace/astro-split-build](https://github.com/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`](https://github.com/spokospace/astro-split-build/tree/main/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} */

```

```bash
npx astro-split-build --config split-build.config.js
```

## Kiedy się przydaje, a kiedy nie {#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 {#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 {#technologie}

<div class="text-center flex gap-4 justify-center flex-wrap mt-8">

</div>
