Czemu nikomu niepotrzebny pakiet z npm ma 3 mln ściągnięć tygodniowo?

Przeglądałem sobie ostatnio reddita i zauważyłem super wątek: „Czemu było prawie 3 miliony ściągnięć pakietu is-odd z npm w ciągu ostatnich 7 dni?”. Nie powiem, sprawa mnie bardzo zaciekawiła. Najpopularniejszy komentarz wyjaśnia tę tajemnicę, jednak ja zagłębię się w temat nieco bardziej. Przedstawię Wam jak to się stało, że is-odd nagle tak zyskał na popularności.

Zapnijcie pasy, bo ruszamy w podróż w głąb uniwersum JavaScript. 🚀


Czemu było tyle pobrań is-odd?

Wyjaśnienie jest dość proste i - jak wspomniałem - tę tajemnicę rozwikłano już na Reddicie. Is-odd to zależność pakietu nanomatch, który jest zależnością micromatch. Micromatch to zależność dla prawie 400 projektów. Między innymi webpacka. Tak - jeżeli używasz webpacka, pewnie ściągasz też pakiet is-odd.

Warto wspomnieć, że is-odd zależy od pakietu is-number - który to pobierany jest około 10 mln razy w ciągu tygodnia.

Tak wyglądają powiązania między poszczególnymi pakietami.

Czas zobaczyć jaki problem rozwiązują te biblioteki. Przejdę po kolei przez kod każdej z nich.


Poziom 0: is-number

Jak sama nazwa wskazuje, ta biblioteka udostępnia metodę, dzięki której możemy się dowiedzieć, czy coś jest liczbą, czy nie. Przez różne zagwostki javascriptowe ciężko czasem odpowiedzieć dobrze na to pytanie, szczególnie jeżeli to nie my kontrolujemy wejście. Sam autor mówi, że używa tej biblioteki do sprawdzania numerów uzyskanych z parsowania stringów. Szczerze, brzmi to jak coś w miarę przydatnego. Kod wygląda tak:

function isNumber(num) {
  var number = +num;

  if ((number - number) !== 0) {
    // Discard Infinity and NaN
    return false;
  }

  if (number === num) {
    return true;
  }

  if (typeof num === 'string') {
    // String parsed, both a non-empty whitespace string and an empty string
    // will have been coerced to 0. If 0 trim the string and see if its empty.
    if (number === 0 && num.trim() === '') {
      return false;
    }
    return true;
  }
  return false;
};



Poziom 1: is-odd

Ok, this is where stuff gets a bit odd.

Ten pakiet służy do sprawdzania, czy liczba jest nieparzysta. Jasne, że w niemal każdym języku programowania jest coś takiego jak operator modulo i jest tak też w JS. Jednak is-odd ma pewną przewagę - na początek sprawdza, czy podany argument to liczba. Zobaczcie sami:

function isOdd(i) {
  if (!isNumber(i)) {
    throw new TypeError('is-odd expects a number.');
  }
  if (Number(i) !== Math.floor(i)) {
    throw new RangeError('is-odd expects an integer.');
  }
  return !!(~~i & 1);
};


Dzięki temu rozwiązaniu rzuci błąd, jeżeli poda się nie-liczbę. Natomiast zwykły JS zrobi tak:

'foo' % 2 == 0
-> false
[] % 2 == 0
-> true


Jeżeli ktoś z Was głowi się czemu tak jest, wynika to z zawołania abstrakcyjnego operatora ToNumber na wyrażeniu po lewej.


Poziom 2: nanomatch


To pakiet, który dodaje do JS funkcjonalność znaną z basha - rozwijanie nazw plików, czyli globbing. Nie są to wyrażenia regularne, a jedynie rozszerzanie predefiniowanych wild cardów. Chodzi o znaki takie jak *, **, ? czy [...], które mogą się pojawić w komendach. Przykład? Gwiazdka w rm -rf *.

Jeszcze 28 marca 2018 pakiet is-odd potrzebny był w matcherze !. Aby stwierdzić, czy faktycznie coś jest zanegowane, autor liczy wykrzykniki. Tylko ich nieparzysta liczba oznacza negację. Tu „pomógł” is-odd.

    .set('not', function() {
      var parsed = this.parsed;
      var pos = this.position();
      var m = this.match(this.notRegex || /^!+/);
      if (!m) return;
      var val = m[0];

      var isNegated = isOdd(val.length); // ← O TUTAJ
      if (parsed === '' && !isNegated) {
        val = '';
      }

      …

    })


Niestety, zabawa się skończyła, bo PR z łatką wyrzucającą is-odd z zależności został zmerge’owany. Teraz w tym miejscu jest używany stary, nudny operator modulo. Jest jednak pewien haczyk - autor biblioteki nie zrobił nowego wydania. Tak więc na npm dalej wisi wersja nanomatch, która potrzebuje is-odd.

Jako ciekawostkę powiem, że w 2017 roku powstał PR, który zaproponował wyrzucenie tej zależności. Autor repozytorium odrzucił zmianę twierdząc, że moduł to lepsze rozwiązanie problemu. Głównie dlatego, że osoba, która zrobiła PR pomyliła się i zamiast porównania (===) użyła przypisania (=) w pierwszym commice. Maintainer skomentował to tak:

Sami sobie odpowiedzcie, czy to podejście jest ok czy nie.

Wracając do naszego śledztwa - ani w micromatch, ani w webpacku is-odd nie był do niczego potrzebny.

Haha JavaScript haha 😂

Łatwo się śmiać w tej sytuacji z JavaScriptu jako języka. Już sama motywacja do stworzenia is-number jest dla osób piszących w innych językach dość zabawna. Is-odd to kolejne kuriozum, które zostało stworzone, by obejść pewne dziwne zachowania operatora modulo.

Jednak słabości JS to nie był główny problem. Nie sądzę też, że jest to wina NPM. W końcu menadżery pakietów są spotykane w wielu innych językach. I nie oszukujmy się, używanie ich jest wygodne i oszczędza mnóstwo czasu. Co więcej - to, że NPM wyraźnie pokazuje zależności pomogło prawdopodobnie odkryć całą sprawę.

Dla mnie problemem jest bezkrytyczne dorzucanie zależności, które zupełnie nie są potrzebne lub mogą być łatwo zastąpione.

Przykład is-odd jest bardzo jaskrawy, w końcu miliony ludzi ściąga niepotrzebną paczkę. Teoretycznie nie szkodzi, że taki kod został dołączony do kilkuset innych bibliotek - w końcu is-odd to bardzo mały moduł i w dodatku zupełnie niegroźny.

Praktyczne problemy z niekontrolowanymi zależnościami

Nic nie jest za darmo

Każda dodatkowa zależność to dodatkowy kod. A kod nie jest za darmo. W przypadku JS wysyłanego do klienta widać to szczególnie wyraźnie, o czym pisał Addy Osmani w artykule „The Cost of JavaScript” (gorąco polecam). Na backendzie długa lista zależności jest w zasadzie odczuwalna tylko przy pierwszym setupie środowiska, procesie budowania czy bardziej poważnych upgrade’ach. Czy zawsze dokładanie kolejnej zależności się opłaca? Moim zdaniem nie.

W Bulldogu mieliśmy trochę niepotrzebnych bibliotek, których wyrzucenie pozwoliło zejść z czasem ładowania strony w przyzwoite regiony. W okresie szybkiego budowania pierwszej wersji portalu zbyt rzadko zadawałem pytanie „hej, a po co to właściwie jest?”. Gdy zbadałem temat okazało się, że mniej więcej połowa JS była zupełnie zbędna (lub do zastąpienia trywialnym snippetem), a i na backendzie nie było problemu z usunięciem kilkunastu zależności. Dobrym przykładem na frontendzie była obecność biblioteki moment.js, która w wersji “min.gz” waży 16kB - była potrzebna w jednym miejscu, w scenariuszu bardzo prostym do zastąpienia.

Bezpieczeństwo

Co się stanie, gdy pakiet zniknie lub przestanie być wspierany? Pamiętacie historię left pad? W 2016 autor biblioteki left-pad - która dopełniała string znakami od lewej aż do wyznaczonej długości - usunął ją z NPM. Wtedy zaczęła się rzeź, bo okazało się, że na tym pakiecie polegało mnóstwo innych pakietów i projektów.

Jest też hipotetyczna sytuacja - co się stanie, gdy twórca jakiegoś pakietu przejdzie na ciemną stronę mocy i doda celowo podatność do kodu? Ba, nawet nie musi to być celowe. Wiadomo, że ludzie popełniają błędy. Ci, którzy robią open source również. Zobaczcie sami bazę danych podatności https://snyk.io/vuln/?packageManager=all. Codziennie pojawia się tam kilkadziesiąt nowych wpisów.

Odpowiedzialność

Jak dla mnie najważniejszy punkt w całej tej historii. Finalnie to Ty bierzesz odpowiedzialność za swój system. Zależności nie dodają się same. Ty masz prawo do powiedzenia „stop”. Twoją robotą jest przemyślenie, czy chcesz się posiłkować zależnością czy wystarczy naklepanie samodzielnie kilku linijek. Każde wyjście ma plusy i minusy. Nikt inny nie zdecyduje, które jest w konkretnym przypadku lepsze.

Ważne, by robić to w miarę możliwości świadomie.