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 java.io.IOException; 022import java.io.InputStream; 023import java.net.URI; 024import java.net.http.HttpClient; 025import java.net.http.HttpRequest; 026import java.net.http.HttpResponse.BodyHandlers; 027import java.nio.file.Files; 028import java.nio.file.Path; 029import java.nio.file.Paths; 030import java.nio.file.StandardCopyOption; 031import java.util.Locale; 032import java.util.zip.ZipEntry; 033import java.util.zip.ZipInputStream; 034import org.apache.commons.compress.archivers.tar.TarArchiveEntry; 035import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; 036import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; 037import org.jdrupes.builder.api.BuildException; 038import org.jdrupes.builder.api.ResourceProvider; 039 040/// Manages cached node js downloads. 041/// 042public class NodeJsDownloader { 043 044 private final ResourceProvider provider; 045 private final Path baseDir; 046 private final Platform platform; 047 048 /// Initializes a new node js downloader. 049 /// 050 /// @param provider the provider 051 /// @param baseDir the base dir 052 /// 053 public NodeJsDownloader(ResourceProvider provider, Path baseDir) { 054 this.provider = provider; 055 this.baseDir = baseDir; 056 platform = detectPlatform(); 057 } 058 059 private Platform detectPlatform() { 060 String opSys = System.getProperty("os.name").toLowerCase(Locale.ROOT); 061 String arch 062 = System.getProperty("os.arch").contains("64") ? "x64" : "x86"; 063 064 if (opSys.contains("win")) { 065 return new Platform("win-" + arch, true, "zip"); 066 } else if (opSys.contains("mac")) { 067 return new Platform("darwin-" + arch, false, "tar.gz"); 068 } else if (opSys.contains("nux")) { 069 return new Platform("linux-" + arch, false, "tar.gz"); 070 } 071 throw new BuildException().from(provider) 072 .message("Unsupported OS: %s", opSys); 073 } 074 075 /// Returns the path to the npm executable for the given version. 076 /// 077 /// @param version the version 078 /// @return the path 079 /// 080 public Path npmExecutable(String version) { 081 Path installDir = downloadAndInstall(version); 082 return executable(installDir); 083 } 084 085 private Path executable(Path installDir) { 086 if (platform.isWindows) { 087 return installDir.resolve("npm.cmd"); 088 } else { 089 return installDir.resolve("bin/npm"); 090 } 091 } 092 093 private Path downloadAndInstall(String version) { 094 Path unpackedIn = baseDir 095 .resolve(String.format("node-v%s-%s", version, platform.name)); 096 if (Files.exists(unpackedIn)) { 097 return unpackedIn; 098 } 099 try { 100 Files.createDirectories(baseDir); 101 var archive = download(baseDir, version); 102 if (archive.toString().endsWith(".zip")) { 103 unzip(baseDir, archive); 104 } else if (archive.toString().endsWith(".tar.gz")) { 105 untarGz(baseDir, archive); 106 } 107 executable(unpackedIn).toFile().setExecutable(true, false); 108 Files.deleteIfExists(archive); 109 return unpackedIn; 110 } catch (IOException | InterruptedException e) { 111 throw new BuildException().from(provider).cause(e); 112 } 113 } 114 115 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 116 private Path download(Path targetDir, String version) 117 throws IOException, InterruptedException { 118 try (var client = HttpClient.newHttpClient()) { 119 String archiveName = String.format("node-v%s-%s.%s", version, 120 platform.name, platform.extension); 121 String downloadUrl = String.format("https://nodejs.org/dist/v%s/%s", 122 version, archiveName); 123 var request = HttpRequest.newBuilder().GET() 124 .uri(URI.create(downloadUrl)).build(); 125 var target = targetDir.resolve(archiveName); 126 var response = client.send(request, BodyHandlers.ofFile(target)); 127 if (response.statusCode() / 100 != 2) { 128 throw new BuildException().from(provider).message( 129 "Attempt to download %s failed with %d", downloadUrl, 130 response.statusCode()); 131 } 132 return target; 133 } 134 } 135 136 private void unzip(Path targetDir, Path zipFile) throws IOException { 137 try (ZipInputStream zis 138 = new ZipInputStream(Files.newInputStream(zipFile))) { 139 while (true) { 140 ZipEntry entry = zis.getNextEntry(); 141 if (entry == null) { 142 break; 143 } 144 Path newPath = targetDir.resolve(entry.getName()); 145 if (entry.isDirectory()) { 146 Files.createDirectories(newPath); 147 } else { 148 Files.createDirectories(newPath.getParent()); 149 Files.copy(zis, newPath, 150 StandardCopyOption.REPLACE_EXISTING); 151 } 152 } 153 } 154 } 155 156 private void untarGz(Path targetDir, Path tarGz) throws IOException { 157 try (InputStream tarGzIn = Files.newInputStream(tarGz); 158 InputStream tarIn = new GzipCompressorInputStream(tarGzIn); 159 TarArchiveInputStream tar = new TarArchiveInputStream(tarIn)) { 160 161 while (true) { 162 TarArchiveEntry entry = tar.getNextEntry(); 163 if (entry == null) { 164 break; 165 } 166 Path newPath = targetDir.resolve(entry.getName()); 167 if (entry.isDirectory()) { 168 Files.createDirectories(newPath); 169 } else if (entry.isSymbolicLink()) { 170 Files.createDirectories(newPath.getParent()); 171 Path target = Paths.get(entry.getLinkName()); 172 Files.createSymbolicLink(newPath, target); 173 } else { 174 Files.createDirectories(newPath.getParent()); 175 Files.copy(tar, newPath, 176 StandardCopyOption.REPLACE_EXISTING); 177 } 178 } 179 } 180 } 181 182 private record Platform(String name, boolean isWindows, 183 String extension) { 184 } 185 186}