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}