Nie tak dawno temu Bun pokazał makra. Spodobały mi się na tyle, iż postanowiłem spróbować przenieść je do Rollupa.
Makra – co to?
Założenie makr jest dość proste: przy wykorzystaniu tzw. atrybutów importów poinformujmy kompilator, iż dana importowana funkcja jest makrem. Następnie kompilator znajdzie wszystkie wywołania tej funkcji i zamieni je na wynik jej wywołania.
Wyobraźmy sobie, iż mamy moduł eksportujący funkcję losującą liczbę, random.js:
Następnie w innym miejscu naszej aplikacji chcemy jej użyć jako makra:
W imporcie pojawiła się dodatkowa część ze słówkiem kluczowym with – to właśnie atrybut importu. W tym przypadku nazywa się type i ma wartość macro, ponieważ chcemy poinformować kompilator, iż ma do czynienia z makrem. W podobny sposób można poinformować, iż wczytujemy np. JSON zamiast JS-a:
Warto przy tym zauważyć, iż na ten moment zdecydowanie częściej można spotkać starszą wersję składni atrybutów (z czasów, gdy jeszcze były asercjami):
Obydwie robią to samo, ale ta druga ma w tej chwili lepsze wsparcie zarówno w środowiskach uruchomieniowych JS-a, jak i we wszelkiego rodzaju narzędziach. To się jednak będzie powoli zmieniać, zatem pozwolę sobie używać w tym artykule nowszej składni.
Zapewne niektóre osoby może zdziwić, iż nowa składnia używa słowa kluczowego with, które istnieje w JS-ie od zawsze. Niemniej jest ono zabronione w trybie ścisłym, a wszystkie moduły są w takim uruchamiane. Innymi słowy: with w imporcie jest całkowicie bezpieczne składniowo, bo prastary mechanizm JS-owy i tak nie może wystąpić w jego pobliżu.
Funkcje zaimportowane z atrybutem { type: 'macro' } zostaną usunięte z ostatecznego kodu i zastąpione wynikiem ich wywołania:
W bunie dzieje się to podczas wywołania komendy build:
W Node.js nie ma jednak wbudowanego żadnego buildera czy choćby kompilatora TS-a, ale za to istnieje bogaty ekosystem bundlerów. Jednym z nich jest Rollup.js. I właśnie do niego postanowiłem dodać eksperymentalną obsługę makr.
Zarys rozwiązania
Rollup.js jest narzędziem, które bardzo łatwo rozwijać dzięki jego dość rozbudowanemu API pluginów. W taki też sposób można do niego dodać obsługę makr. Tak naprawdę plugin będzie składał się z dwóch części:
- wykrycia, iż dany moduł to moduł zawierający makra, dzięki czemu będzie można go usunąć z ostatecznego bundle’a,
- podmiany wywołań konkretnych makr na ich wyniki.
Obydwie części wykonamy przy pomocy tzw. hooków (haków), które pozwalają się wpinać w poszczególne etapy bundle’owania, takie jak parsowanie opcji przekazanych do rollupa czy wczytywanie poszczególnych modułów. Do różnych operacji na kodzie wykorzystamy AST.
Bierzmy się zatem do pracy!
Wykrywanie modułu z makrami
W przypadku Rollupa nie da się tak po prawdzie całkowicie usunąć jakiegoś modułu z bundle’a. Jedyne, co można zrobić, to poinformować bundler, iż dany moduł jest external (zewnętrzny). To sprawi, iż Rollup pozostawi import tego modułu w spokoju. Jest to zdecydowanie krok w dobrą stronę, bo o wiele prościej będzie usunąć taki import, niż próbować usunąć cały zbundle’owany kod modułu.
Wykrywanie modułu z makrami jest stosunkowo proste. Rollup udostępnia hook resolveId(), który pozwala m.in. właśnie na oznaczenie modułu jako zewnętrzny.
Ale zanim przejdziemy do mięska, trzeba stworzyć szkielet naszego pluginu:
Typowy plugin do Rollupa jest funkcją (1), która zwraca obiekt (2). Musi on mieć własność name (3) zawierającą nazwę pluginu. Pozostałymi własnościami są poszczególne hooki.
Dodajmy zatem nasz hook resolveId():
Hook resolveId() przyjmuje kilka parametrów (1):
- importee – nazwa wczytywanego modułu,
- importer – ścieżka wczytującego modułu,
- options – obiekt z dodatkowymi informacjami o wczytywanym module, w tym atrybuty (nazywane tutaj asercjami – assertions).
Nas interesują w zasadzie tylko dwie rzeczy – nazwa wczytywanego modułu oraz atrybuty. jeżeli istnieje atrybut type i ma on wartość macro (2), zwracamy obiekt (3), który wskazuje, iż ten konkretny moduł importee (4) powinien być traktowany jako zewnętrzny (5) i jego kod nie powinien zostać dorzucony do bundle’a. Z kolei jeżeli nie ma atrybutu type albo ma on inną wartość, wówczas zwracamy null (6), co dla Rollupa oznacza, iż dla tego modułu powinien odpalić hooki resolveId() z kolejnych pluginów albo użyć domyślnego, jeżeli żadnego innego pluginu nie ma.
Taki hook resolveId() wystarczy, żeby import modułu z makrem został nienaruszony, dzięki czemu można go będzie łatwo usunąć.
Usuwanie importów modułów z makrami
Modyfikacje kodu wczytywanych modułów przeprowadza się w hooku transform(). Daje on dostęp do kodu modułu i oczekuje, iż po wszystkich zmianach zwrócony zostanie nowy kod. Rollup pozwala także zwrócić mapę dla kodu po zmianach, co może być przydatne, jeżeli zaszły większe zmiany. W przypadku tego eksperymentu pozwoliłem sobie pominąć mapy, bo niepotrzebnie komplikowałyby całość.
Dodajmy zatem hook transform() do naszego pluginu:
Na ten moment potrzebny będzie nam tylko pierwszy argument, czyli kod modułu w formie stringa (1).
Skoro mamy stringa z kodem, to w teorii można by wykorzystać wyrażenia regularne, żeby znaleźć interesujący nas fragment i go podmienić (jest choćby biblioteka od tego – swoją drogą, bardzo dobra!). Niemniej takie wyrażenie regularne musiałoby być mocno skomplikowane (bo samych sposobów wstawienia spacji między poszczególnymi elementami importu jest co najmniej kilka…), przez co całość byłaby podatna na błędy. Na szczęście istnieje inny, bardziej elegancki sposób: sparsowanie kodu do postaci AST.
Tutaj z pomocą przychodzi nam Rollup, który wewnątrz hooka transform() udostępnia metodę this.parse(). Wykorzystuje ona parser Acorn (używany wewnętrznie przez Rollupa), aby sparsować kod do drzewka AST. Niemniej samo drzewko nie da nam zbyt dużo, bo brakuje nam jeszcze sposobu, aby poruszać się po nim. Tego Rollup już nie oferuje, ale na szczęście istnieją odpowiednie narzędzia do tego. Na potrzeby tego eksperymentu użyjemy pakietu estree-walker:
Zamieniamy kod na AST przy pomocy wspomnianego już this.parse() (1). Następnie przechodzimy przez to drzewko przy pomocy funkcji walk (2), którą zaimportowaliśmy z pakietu estree-walker (3). Szukamy importów do makr (4). jeżeli takowy znajdziemy, usuwamy go (5). Szukanie składa się z dwóch zasadniczych części: sprawdzenia, czy typ węzła to ImportDeclaration, oraz wywołania funkcji isMacroImport().
Logika tej funkcji jest bardzo podobna do tej, którą zastosowaliśmy w hooku resolveId():
Sprawdzamy, czy import ma atrybuty (1) – czyli własność node.assertions (uwielbiam chaos w nazewnictwie!). jeżeli tak, pobieramy pierwszy z nich (2) i sprawdzamy, czy ma odpowiednią nazwę i wartość (3).
Do zobaczenia, jak wygląda AST od środka, polecam narzędzie AST Explorer. Co prawda akurat atrybuty importów to ta jedna rzecz, której jeszcze nie obsługuje, ale przy całej reszcie zabaw z AST na potrzeby tego eksperymentu jest jak znalazł.
Zapisywanie informacji o makrach
Niemniej jeżeli ot tak usuniemy wszystkie importy makr, pozbawimy się informacji o tym, które wywołania funkcji powinniśmy następnie podmienić. Wypada zatem zapisać sobie gdzieś informacje z importów. W tym celu stwórzmy mapę makr:
Makra będziemy zapisywali do zmiennej macros (1), w której znajduje się pusta mapa. Logika do zapisu znajduje się w funkcji extractMacros() (2). Przekazujemy do niej mapę, węzeł oraz ścieżkę do aktualnie transformowanego modułu. Ścieżkę tę dostajemy jako drugi argument hooku transform() (3). Funkcja extractMacros() dla wszystkich specyfikatora importu (4) ustala jego oryginalną nazwę (5), a następnie zapisuje tę informację wraz ze ścieżką do importowanego modułu pod lokalną nazwą makra (6). Ścieżkę uzyskujemy poprzez pobranie katalogu w tej chwili transformowanego modułu (7) a następnie rozwiązując ścieżkę do importowanego modułu (wyciągniętą z własności node.source.value) względem niego (8) przy pomocy odpowiednich funkcji z modułu node:path (9).
Brzmi to skomplikowanie, więc rozbijmy to na części. Zacznijmy od specyfikatorów (specifiers). W składni importu to określenie na to, co importujemy, np:
Specyfikatorem w tym kodzie jest test. W tym konkretnym przypadku lokalna nazwa (czyli ta używana w module importującym), jak i ta oryginalna (czyli ta użyta do eksportu w module importowanym), są takie same. Ale nazwy te mogą się też różnić, gdy pojawią się aliasy:
W tym wypadku lokalna nazwa to t, a oryginalna – test. Z racji tego, iż nazwy te mogą się różnić między sobą, musimy zapisać je obie.
Warto też zwrócić uwagę na szczególny przypadek specyfikatora – specyfikator domyślnego importu:
W takim wypadku węzeł nie zawiera oryginalnej nazwy, a jedynie lokalną. Nasz kod ma specjalny warunek na tę okazję:
Jeśli typ specyfikatora to ImportDefaultSpecifier (1) – czyli domyślny import – wówczas użyj default (2) jako oryginalnej nazwy. W przeciwnym razie weź nazwę z własności imported specyfikatora (3).
Ta sztuczka pozwoli nam później, przy samym wywoływaniu makra, nieco uprościć kod. Domyślne importy bowiem to w rzeczywistości skrócona wersja takiego zapisu:
Inny słowy: domyślne importy można traktować jak aliasy do importu nazwanego eksportu default.
Z kolei w przypadku ścieżki potrzebujemy absolutnej (czemu, to okaże się później) – a więc rozpoczynającej się od /. Niemniej nasz węzeł importu zawiera ją najprawdopodobniej w formie ścieżki relatywnej – zaczynającej się od ./. Takie ścieżki zawsze podawane są względem katalogu modułu importującego. Zatem przy pomocy funkcji dirname() wyciągamy absolutną ścieżkę do katalogu ze ścieżki do modułu, a następnie rozwiązujemy ścieżkę do modułu z makrami przy pomocy funkcji resolvePath(). Dzięki temu uzyskujemy absolutną ścieżkę do importowanego modułu.
Moje przygody z ESM sugerują, iż żeby kod ze ścieżkami działał poprawnie na wielkiej trójcy systemów (Linux, macOS, Windows), wypada zawsze wymuszać ścieżki POSIX-owe – czyli z `/` zamiast `\`. Inaczej w którymś momencie niemal na pewno coś się wywali z powodu złego slasha. Od siebie polecam pakiet pathe, który ma identyczne API jak node:path i można go używać jako bezpośredni zamiennik.
Tak zebrane informacje wrzucamy do modułu z makrami i jesteśmy gotowi, by odpalić jakieś makro!
Wyszukiwanie makr w kodzie
Zanim jednak je odpalimy, musimy je znaleźć. Na szczęście istnieje odpowiedni typ węzła, którego możemy po prostu poszukać w drzewku AST – CallExpression. Te węzły to nic innego jak wywołania funkcji.
Dodajmy zatem kolejny if do naszego walka(), tym razem dla wywołań funkcji:
Po upewnieniu się, iż mamy do czynienia z wywołaniem funkcji (1), pobieramy jej nazwę (2). Sprawdzamy, czy taka nazwa występuje w naszej mapie z makrami (3). jeżeli tak, wyciągamy informacje o makrze do zmiennej macro (4).
Odpalanie makr
Skoro już znaleźliśmy makro, pora je odpalić:
Cała logika odpalania makra została zamknięta w asynchronicznej funkcji executeMacro() (1), do której przekazujemy obiekt makra, a która zwraca Promise z wynikiem wywołania makra. Warto przy tym zwrócić uwagę, iż to wymusza zrobienie całej metody enter() asynchroniczną (2). Równocześnie wymusza też wymianę walk() na asyncWalk() (3) – bo to pierwsze jest wyłącznie synchroniczne.
Natomiast funkcja executeMacro() prezentuje się następująco:
Wykorzystuje ona workery, aby odpalić kod makra “z dala” od reszty kodu i następnie jedynie przekazać jego wynik.
Trochę się tutaj dzieje, więc rozbijmy sobie tę funkcję na dwie części. Pierwsza część odpowiedzialna jest za wygenerowanie kodu workera:
Na początku ustalamy lokalną nazwę importu – alias (1). jeżeli mamy do czynienia z domyślnym importem (czyli default), naszym aliasem będzie tempName. Wynika to z tego, iż default jest zarezerwowanym słowem w JS-ie i nie może ot tak wystąpić jako nazwa funkcji. Dlatego też musimy wybrać jakąś inną nazwę. Nie chciało mi się myśleć, więc jest tempName. Z kolei jeżeli nazwa importu jest inna, używamy jej bezpośrednio. Następnie zamieniamy absolutną ścieżkę modułu na URL (2) przy pomocy funkcji pathToFileURL() z modułu node:url. Moduły w Node.js wymagają, by były wczytywane po URL-u. A to oznacza, iż ścieżki na Windowsie (nawet zapisane z / zamiast \) będą sprawiać problemy – a to z powodu tego, iż litera dysku (np. c:) zostanie potraktowana jako schemat. Dlatego bezpieczniej jest przekształcić absolutną ścieżkę na URL ze schematem file:. Mając przygotowany alias i URL modułu generujemy kod workera (3). Następnie tworzymy tymczasowy plik (4) z rozszerzeniem .mjs (5) – tutaj przy pomocy funkcji temporaryFile() z pakietu tempy. Do tego pliku zapisujemy wygenerowany kod (6).
Wykorzystanie tymczasowego pliku tłumaczy, czemu potrzebowaliśmy absolutnej ścieżki do modułu z makrami. Taki tymczasowy plik jest tworzony w systemowym katalogu przeznaczonym do -przechowywania plików tymczasowych. Z tego też powodu relatywna ścieżka wskazywałaby na nieistniejący plik. Ścieżka absolutna zapewnia z kolei poprawne wczytanie modułu.
Prawdę mówiąc, miałem nadzieję, iż uda się całość zrobić bez użycia tymczasowego pliku, zwłaszcza, iż konstruktor workera pozwala podać kod:
Problem polega na tym, iż tak wykonany kod odpala się jako moduł CJS – a więc nie działają w nim importy.
W teorii można też przekazać do konstruktora Data URL z kodem JS, ale ta opcja też nie chciała działać. Node.js upierał się, iż URL jest niepoprawny, podczas gdy przeglądarka nie miała problemu z jego odpaleniem. Ostatecznie zatem musiałem użyć tymczasowego pliku. Na wszelki wypadek dałem mu rozszerzenie .mjs, żeby wymusić na Node.js traktowanie go jako modułu ES.
Przyjrzyjmy się jeszcze przez chwilę samemu kodowi workera:
Jest bardzo prosty, bo jego jedynym zadaniem jest zaimportowanie makra (1), wywołanie go – na wszelki wypadek z awaitem (2), a następnie przesłanie do bundlera wyniku tego wywołania (3). W tym celu posługuje się obiektem parentPort zaimportowanym z modułu node:worker_threads (4). Ten schemat jest bardzo podobny do tego, jak działa komunikacja między głównym wątkiem a workerem w przeglądarce.
Z kolei druga część funkcji executeMacro() wygląda następująco:
Zwraca ona Promise (1), dzięki czemu można poczekać na wykonanie executeMacro() przy pomocy zwykłego await. Wewnątrz tego Promise‘a tworzymy workera na podstawie naszego tymczasowego pliku (2). jeżeli prześle on jakąś wiadomość, rozwiązujemy obietnicę z nią jako wynikiem (3). W przypadku błędu (4) lub przedwczesnego zakończenia workera (5) odrzucamy obietnicę.
Podmiana makra na wynik
Skoro już wywołaliśmy makro i dostaliśmy jego wynik, pora zamieścić go w kodzie zamiast samego wywołania. Tutaj jednak pojawia się problem: z workera otrzymujemy JS-ową wartość – czy to typu prostego, czy też obiekt lub tablicę. A my potrzebujemy węzła, który można wsadzić do drzewka AST. Tu z pomocą przychodzi nam nasz stary znajomy – this.parse():
Wywołujemy parse() na wyniku zwróconym przez makro (1). Z racji tego, iż parser przyjmuje wyłącznie stringi, najpierw musimy przepuścić ten wynik przez JSON.stringify(). Dodatkowo otaczamy całość nawiasami. Jest to konieczne, ponieważ w JS-ie istnieje pewna dwuznaczność – { a: 1 } może oznaczać zarówno obiekt z własnością a, jak i tzw. instrukcję z etykietą zamkniętą w bloku. W przypadku tej niejasności parser wybiera instrukcję z etykietą w bloku… Otoczenie całości nawiasami usuwa tę dwuznaczność, ponieważ instrukcje nie mogą znajdować się wewnątrz nawiasów, a więc musi to być tzw. wyrażenie obiektowe (czyli inaczej – po prostu obiekt). Warto tutaj też zauważyć, iż zapisaliśmy sobie this.parse() do zmiennej parse() (2) – a to dlatego, iż wewnątrz enter() mamy inny kontekst this (węzeł AST). Samo sparsowanie wyniku do AST to jednak nie wszystko. Parser zwraca bowiem cały program, a takowym nie możemy zastąpić wywołania funkcji. Musimy wyłuskać z programu sam wynik – i tym zajmuje się funkcja getValueNode() (3). Gdy już wyłuskamy odpowiedni węzeł, zastępujemy nim ten obecny (4).
Funkcja getValueNode() wygląda następująco:
Przekazujemy do niej nasz wynik w formie AST (1). Następnie definiujemy sobie, jakich węzłów szukamy (2) – w naszym przypadku będą to literały (3), obiekty (4) oraz tablice (5). Dla parsera literałem są praktycznie wszystkie wartości prymitywne (oprócz symboli; niemniej tych i tak nie da się przesłać z workera, więc who cares). Następnie tworzymy sobie zmienną valueNode (6) i przechodzimy przez nasze drzewko przy pomocy znanej już nam funkcji walk() (7). jeżeli węzeł nie jest jednym z dozwolonych typów (8), nic nie robimy (9). W innym wypadku przypisujemy go do zmiennej valueNode (10) oraz wywołujemy this.skip() (11), informując walkera, iż nie chcemy wchodzić głębiej w drzewko. Na samym końcu zwracamy znaleziony węzeł (12).
Obsługa argumentów
Niemniej niektóre makra mają parametry. I wypadałoby takie też obsługiwać. Na szczęście dodanie podstawowego wsparcia dla przekazywania argumentów do makr jest stosunkowo proste. I można to zrobić np. tak:
Tworzymy sobie tablicę typów argumentów, które w tej chwili obsługujemy (1) – to dokładnie te same typy, które obsługujemy przy wyniku. Następnie do zmiennej args pobieramy sobie argumenty przekazane do makra z własności node.arguments (2). Przy pomocy args.every() (3) sprawdzamy, czy wszystkie argumenty są przez nas obsługiwane (4). jeżeli nie (5), rzucamy błąd (6). W innym przypadku wywołujemy makro, przekazując argumenty jako drugi argument (7).
W executeMacro() pojawił się z kolei kod odpowiedzialny za formatowanie argumentów:
Dla każdego argumentu w formie AST (1) generujemy odpowiadający mu kod przy pomocy funkcji generate() (2) z pakietu escodegen (3). Następnie łączymy wszystkie argumenty przy pomocy przecinka (4) – żeby uzyskać poprawny fragment kodu z argumentami. Mając takowy, podstawiamy go do wywołania makra w kodzie workera (5).
Pakiet escodegen to taka odwrotność parsera – zwraca kod z podanego drzewka AST. Wykorzystaliśmy go tutaj, ponieważ do workera potrzebujemy kodu JS, nie – AST.
Zwrócenie zmodyfikowanego kodu
Skoro już wywołaliśmy makro i podstawiliśmy za nie jego wynik, została ostatnia rzecz: zwrócenie zmodyfikowanego kodu z hooka transform():
Raz jeszcze wykorzystujemy tutaj funkcję generate() (1) z pakietu escodegen, żeby wygenerować kod ze zmodyfikowanego przez nas drzewka AST.
I tym sposobem zaimplementowaliśmy makra w Rollupie!
Wnioski
To był zadziwiająco przyjemny i stosunkowo mało złożony projekt. A efekt końcowy jest zdecydowanie jednym z lepszych, jakie udało mi się osiągnąć w przypadku moich eksperymentów. Jasne, jest tu sporo niedoskonałości, które można doszlifować, np.
- Co jeżeli makro zostało przykryte przez jakąś zmienną w zasięgu? Wówczas nie powinniśmy podmieniać takiego wywołania. Tutaj na pomoc może przyjść funkcja attachScopes() z pakietu @rollup/pluginutils, która pozwala sprawdzić, czy dana nazwa zmiennej jest z zasięgu globalnego czy lokalnego. A iż importy muszą być globalne, to pozwoli to wykryć potencjalne kolizje.
- Wypadałoby też obsługiwać inne rodzaje argumentów, np. zmienne. Oczywiście nie wszystkie się da, ale jeżeli zmienna zawiera choćby liczbę, to powinno to być możliwe. Problemem jest trzymanie informacji o tym, gdzie jest która zmienna, żeby móc gwałtownie odczytać jej wartość.
- Fajnie byłoby wspierać bardziej “egzotyczne” wyniki, jak np. bufory czy obiekty odpowiedzi generowane przez fetch(). Ale to już wymaga niestandardowej serializacji.
Obecne rozwiązanie, po lekkim refactorze, można znaleźć na GitHubie. Być może komuś się do czegoś przyda. Ja, w każdym razie, miałem przyjemność w trakcie tworzenia tego kodu. A to chyba najważniejsze.