001/*
002 * JDrupes Builder
003 * Copyright (C) 2026 Michael N. Lipp
004 * 
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.builder.ext.nodejs;
020
021import com.google.common.flogger.FluentLogger;
022import java.io.IOException;
023import java.io.InputStream;
024import java.net.URI;
025import java.net.http.HttpClient;
026import java.net.http.HttpRequest;
027import java.net.http.HttpResponse.BodyHandlers;
028import java.nio.file.Files;
029import java.nio.file.Path;
030import java.nio.file.Paths;
031import java.nio.file.StandardCopyOption;
032import java.util.Locale;
033import java.util.Map;
034import java.util.concurrent.ConcurrentHashMap;
035import java.util.zip.ZipEntry;
036import java.util.zip.ZipInputStream;
037import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
038import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
039import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
040import org.jdrupes.builder.api.BuildException;
041import org.jdrupes.builder.api.ResourceProvider;
042
043/// Manages cached node js downloads.
044///
045public class NodeJsDownloader {
046
047    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
048    private static Map<Path, Object> installLocks = new ConcurrentHashMap<>();
049    private final ResourceProvider provider;
050    private final Path baseDir;
051    private final Platform platform;
052
053    /// Initializes a new node js downloader.
054    ///
055    /// @param provider the provider
056    /// @param baseDir the cache directory for NodeJs downloads
057    ///
058    public NodeJsDownloader(ResourceProvider provider, Path baseDir) {
059        this.provider = provider;
060        this.baseDir = baseDir;
061        platform = detectPlatform();
062        logger.atFine().log("%s uses NodeJs Platform: %s", provider, platform);
063    }
064
065    private Platform detectPlatform() {
066        String opSys = System.getProperty("os.name").toLowerCase(Locale.ROOT);
067        String arch
068            = System.getProperty("os.arch").contains("64") ? "x64" : "x86";
069
070        if (opSys.contains("win")) {
071            return new Platform("win-" + arch, true, "zip");
072        } else if (opSys.contains("mac")) {
073            return new Platform("darwin-" + arch, false, "tar.gz");
074        } else if (opSys.contains("nux")) {
075            return new Platform("linux-" + arch, false, "tar.gz");
076        }
077        throw new BuildException().from(provider)
078            .message("Unsupported OS: %s", opSys);
079    }
080
081    /// Returns the path to the npm executable for the given version.
082    ///
083    /// @param version the version
084    /// @return the path
085    ///
086    public Path npmExecutable(String version) {
087        Path installDir = downloadAndInstall(version);
088        return executable(installDir);
089    }
090
091    private Path executable(Path installDir) {
092        if (platform.isWindows) {
093            return installDir.resolve("npm.cmd");
094        } else {
095            return installDir.resolve("bin/npm");
096        }
097    }
098
099    @SuppressWarnings("PMD.AvoidSynchronizedStatement")
100    private Path downloadAndInstall(String version) {
101        Path unpackedIn = baseDir
102            .resolve(String.format("node-v%s-%s", version, platform.name));
103        var lock = installLocks.computeIfAbsent(unpackedIn, _ -> new Object());
104        synchronized (lock) {
105            if (Files.exists(unpackedIn)) {
106                logger.atFine().log(
107                    "Using NodeJs %s at %s", version, unpackedIn);
108                return unpackedIn;
109            }
110            try {
111                Files.createDirectories(baseDir);
112                var archive = download(baseDir, version);
113                if (archive.toString().endsWith(".zip")) {
114                    unzip(baseDir, archive);
115                } else if (archive.toString().endsWith(".tar.gz")) {
116                    untarGz(baseDir, archive);
117                }
118                executable(unpackedIn).toFile().setExecutable(true, false);
119                Files.deleteIfExists(archive);
120                logger.atFine().log(
121                    "Installed NodeJs %s to %s", version, unpackedIn);
122                return unpackedIn;
123            } catch (IOException | InterruptedException e) {
124                throw new BuildException().from(provider).cause(e);
125            }
126        }
127    }
128
129    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
130    private Path download(Path targetDir, String version)
131            throws IOException, InterruptedException {
132        try (var client = HttpClient.newHttpClient()) {
133            String archiveName = String.format("node-v%s-%s.%s", version,
134                platform.name, platform.extension);
135            String downloadUrl = String.format("https://nodejs.org/dist/v%s/%s",
136                version, archiveName);
137            logger.atFine().log("Downloading %s", downloadUrl);
138            var request = HttpRequest.newBuilder().GET()
139                .uri(URI.create(downloadUrl)).build();
140            var target = targetDir.resolve(archiveName);
141            var response = client.send(request, BodyHandlers.ofFile(target));
142            if (response.statusCode() / 100 != 2) {
143                throw new BuildException().from(provider).message(
144                    "Attempt to download %s failed with %d", downloadUrl,
145                    response.statusCode());
146            }
147            return target;
148        }
149    }
150
151    private void unzip(Path targetDir, Path zipFile) throws IOException {
152        try (ZipInputStream zis
153            = new ZipInputStream(Files.newInputStream(zipFile))) {
154            while (true) {
155                ZipEntry entry = zis.getNextEntry();
156                if (entry == null) {
157                    break;
158                }
159                Path newPath = targetDir.resolve(entry.getName());
160                if (entry.isDirectory()) {
161                    Files.createDirectories(newPath);
162                } else {
163                    Files.createDirectories(newPath.getParent());
164                    Files.copy(zis, newPath,
165                        StandardCopyOption.REPLACE_EXISTING);
166                }
167            }
168        }
169    }
170
171    private void untarGz(Path targetDir, Path tarGz) throws IOException {
172        try (InputStream tarGzIn = Files.newInputStream(tarGz);
173                InputStream tarIn = new GzipCompressorInputStream(tarGzIn);
174                TarArchiveInputStream tar = new TarArchiveInputStream(tarIn)) {
175
176            while (true) {
177                TarArchiveEntry entry = tar.getNextEntry();
178                if (entry == null) {
179                    break;
180                }
181                Path newPath = targetDir.resolve(entry.getName());
182                if (entry.isDirectory()) {
183                    Files.createDirectories(newPath);
184                } else if (entry.isSymbolicLink()) {
185                    Files.createDirectories(newPath.getParent());
186                    Path target = Paths.get(entry.getLinkName());
187                    Files.createSymbolicLink(newPath, target);
188                } else {
189                    Files.createDirectories(newPath.getParent());
190                    Files.copy(tar, newPath,
191                        StandardCopyOption.REPLACE_EXISTING);
192                }
193            }
194        }
195    }
196
197    private record Platform(String name, boolean isWindows,
198            String extension) {
199    }
200
201}