From b7e3fe69ad17928053d57d6478ac1e37b27e7bd5 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 8 Mar 2026 23:12:54 -0600 Subject: [PATCH] feat(releases): add Node.js distribution fetchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nodedist: generic fetcher for any Node.js-style dist index.json API. Returns raw API entries with no transformation or normalization. Uses iter.Seq2 for a paginated interface consistent across sources. node: calls nodedist twice — official builds and unofficial builds (musl, loong64, etc.) — yielding one batch per source. --- internal/releases/node/node.go | 39 ++++++ internal/releases/node/node_test.go | 36 +++++ internal/releases/nodedist/nodedist.go | 108 +++++++++++++++ internal/releases/nodedist/nodedist_test.go | 143 ++++++++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 internal/releases/node/node.go create mode 100644 internal/releases/node/node_test.go create mode 100644 internal/releases/nodedist/nodedist.go create mode 100644 internal/releases/nodedist/nodedist_test.go diff --git a/internal/releases/node/node.go b/internal/releases/node/node.go new file mode 100644 index 0000000..b5507f5 --- /dev/null +++ b/internal/releases/node/node.go @@ -0,0 +1,39 @@ +// Package node fetches Node.js releases from both official and unofficial +// build sources. +// +// Official builds cover the standard platforms (linux-x64, osx-arm64, win-x64, +// etc.). Unofficial builds add musl, loong64, and other targets that the +// official CI doesn't produce. +// +// Both sources use the same index format, served by [nodedist]. +package node + +import ( + "context" + "iter" + "net/http" + + "github.com/webinstall/webi-installers/internal/releases/nodedist" +) + +const ( + officialURL = "https://nodejs.org/download/release" + unofficialURL = "https://unofficial-builds.nodejs.org/download/release" +) + +// Fetch retrieves Node.js releases from both official and unofficial sources. +// Yields one batch per source (official first, then unofficial). +func Fetch(ctx context.Context, client *http.Client) iter.Seq2[[]nodedist.Entry, error] { + return func(yield func([]nodedist.Entry, error) bool) { + for entries, err := range nodedist.Fetch(ctx, client, officialURL) { + if !yield(entries, err) { + return + } + } + for entries, err := range nodedist.Fetch(ctx, client, unofficialURL) { + if !yield(entries, err) { + return + } + } + } +} diff --git a/internal/releases/node/node_test.go b/internal/releases/node/node_test.go new file mode 100644 index 0000000..f16ea30 --- /dev/null +++ b/internal/releases/node/node_test.go @@ -0,0 +1,36 @@ +package node_test + +import ( + "context" + "net/http" + "testing" + + "github.com/webinstall/webi-installers/internal/releases/node" +) + +func TestFetchCombinesSources(t *testing.T) { + if testing.Short() { + t.Skip("skipping network test in short mode") + } + + ctx := context.Background() + client := &http.Client{} + + var batches int + var total int + for entries, err := range node.Fetch(ctx, client) { + if err != nil { + t.Fatalf("batch %d: %v", batches, err) + } + batches++ + total += len(entries) + } + + if batches != 2 { + t.Errorf("got %d batches, want 2 (official + unofficial)", batches) + } + if total < 100 { + t.Errorf("got %d total entries, expected at least 100", total) + } + t.Logf("fetched %d entries in %d batches", total, batches) +} diff --git a/internal/releases/nodedist/nodedist.go b/internal/releases/nodedist/nodedist.go new file mode 100644 index 0000000..7790e05 --- /dev/null +++ b/internal/releases/nodedist/nodedist.go @@ -0,0 +1,108 @@ +// Package nodedist fetches a Node.js-style distribution index. +// +// Node.js publishes a JSON index of all releases at: +// +// https://nodejs.org/download/release/index.json +// +// Unofficial builds (musl, etc.) use the same format at: +// +// https://unofficial-builds.nodejs.org/download/release/index.json +// +// This package fetches and deserializes that index. It does not classify, +// normalize, or transform the data — the caller gets what the API returns. +package nodedist + +import ( + "context" + "encoding/json" + "fmt" + "iter" + "net/http" +) + +// Entry is one release from a Node.js distribution index. +// Fields mirror the upstream JSON schema. +type Entry struct { + Version string `json:"version"` // "v25.8.0" + Date string `json:"date"` // "2026-03-03" + Files []string `json:"files"` // ["linux-arm64", "osx-arm64-tar", ...] + NPM string `json:"npm"` // "11.11.0" + V8 string `json:"v8"` // "14.1.146.11" + UV string `json:"uv"` // "1.51.0" + Zlib string `json:"zlib"` // "1.3.1" + OpenSSL string `json:"openssl"` // "3.5.5" + Modules string `json:"modules"` // "141" + LTS LTS `json:"lts"` // false or "Jod" + Security bool `json:"security"` // true if security release +} + +// LTS holds the long-term support status. The upstream API encodes this as +// either the boolean false or a codename string like "Jod" or "Iron". +// An empty string means the release is not LTS. +type LTS string + +func (l *LTS) UnmarshalJSON(data []byte) error { + // false → "" + if string(data) == "false" { + *l = "" + return nil + } + + // "Codename" → Codename + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("nodedist: unexpected lts value: %s", data) + } + *l = LTS(s) + return nil +} + +func (l LTS) MarshalJSON() ([]byte, error) { + if l == "" { + return []byte("false"), nil + } + return json.Marshal(string(l)) +} + +// Fetch retrieves the Node.js distribution index from baseURL. +// +// The iterator yields one batch per HTTP response. The Node.js index API +// returns all releases in a single response, so there will be exactly one +// yield. The iterator interface exists so that callers use the same pattern +// for paginated sources (like GitHub). +// +// Standard base URLs: +// - https://nodejs.org/download/release +// - https://unofficial-builds.nodejs.org/download/release +func Fetch(ctx context.Context, client *http.Client, baseURL string) iter.Seq2[[]Entry, error] { + return func(yield func([]Entry, error) bool) { + url := baseURL + "/index.json" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + yield(nil, fmt.Errorf("nodedist: %w", err)) + return + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + yield(nil, fmt.Errorf("nodedist: fetch %s: %w", url, err)) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + yield(nil, fmt.Errorf("nodedist: fetch %s: %s", url, resp.Status)) + return + } + + var entries []Entry + if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil { + yield(nil, fmt.Errorf("nodedist: decode %s: %w", url, err)) + return + } + + yield(entries, nil) + } +} diff --git a/internal/releases/nodedist/nodedist_test.go b/internal/releases/nodedist/nodedist_test.go new file mode 100644 index 0000000..d451417 --- /dev/null +++ b/internal/releases/nodedist/nodedist_test.go @@ -0,0 +1,143 @@ +package nodedist_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/webinstall/webi-installers/internal/releases/nodedist" +) + +// Minimal fixture from the real Node.js dist API. +const testIndex = `[ + { + "version": "v22.14.0", + "date": "2025-02-11", + "files": ["linux-arm64", "linux-x64", "osx-arm64-tar", "win-x64-zip", "src", "headers"], + "npm": "10.9.2", + "v8": "12.4.254.21", + "uv": "1.49.2", + "zlib": "1.3.0.1-motley-82a6be0", + "openssl": "3.0.15+quic", + "modules": "127", + "lts": "Jod", + "security": false + }, + { + "version": "v23.7.0", + "date": "2025-02-04", + "files": ["linux-arm64", "linux-x64", "osx-arm64-tar", "win-x64-zip"], + "npm": "10.9.2", + "v8": "13.2.152.16", + "uv": "1.49.2", + "zlib": "1.3.0.1-motley-82a6be0", + "openssl": "3.0.15+quic", + "modules": "131", + "lts": false, + "security": true + } +]` + +func TestFetch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/index.json" { + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(testIndex)) + })) + defer srv.Close() + + ctx := context.Background() + var got []nodedist.Entry + + for entries, err := range nodedist.Fetch(ctx, srv.Client(), srv.URL) { + if err != nil { + t.Fatalf("Fetch: %v", err) + } + got = append(got, entries...) + } + + if len(got) != 2 { + t.Fatalf("got %d entries, want 2", len(got)) + } + + // First entry: LTS release + if got[0].Version != "v22.14.0" { + t.Errorf("entry[0].Version = %q, want %q", got[0].Version, "v22.14.0") + } + if got[0].Date != "2025-02-11" { + t.Errorf("entry[0].Date = %q, want %q", got[0].Date, "2025-02-11") + } + if got[0].LTS != "Jod" { + t.Errorf("entry[0].LTS = %q, want %q", got[0].LTS, "Jod") + } + if got[0].Security { + t.Error("entry[0].Security = true, want false") + } + if len(got[0].Files) != 6 { + t.Errorf("entry[0].Files len = %d, want 6", len(got[0].Files)) + } + + // Second entry: non-LTS, security release + if got[1].Version != "v23.7.0" { + t.Errorf("entry[1].Version = %q, want %q", got[1].Version, "v23.7.0") + } + if got[1].LTS != "" { + t.Errorf("entry[1].LTS = %q, want empty (non-LTS)", got[1].LTS) + } + if !got[1].Security { + t.Error("entry[1].Security = false, want true") + } +} + +func TestFetchHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "rate limited", http.StatusTooManyRequests) + })) + defer srv.Close() + + ctx := context.Background() + for _, err := range nodedist.Fetch(ctx, srv.Client(), srv.URL) { + if err == nil { + t.Fatal("expected error for 429 response") + } + return + } +} + +func TestLTSMarshalRoundTrip(t *testing.T) { + // LTS codename + entry := nodedist.Entry{LTS: "Jod"} + data, err := json.Marshal(entry) + if err != nil { + t.Fatal(err) + } + + var got nodedist.Entry + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.LTS != "Jod" { + t.Errorf("LTS roundtrip: got %q, want %q", got.LTS, "Jod") + } + + // Non-LTS + entry2 := nodedist.Entry{LTS: ""} + data2, err := json.Marshal(entry2) + if err != nil { + t.Fatal(err) + } + + var got2 nodedist.Entry + if err := json.Unmarshal(data2, &got2); err != nil { + t.Fatal(err) + } + if got2.LTS != "" { + t.Errorf("non-LTS roundtrip: got %q, want empty", got2.LTS) + } +}