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.File; 023import java.io.IOException; 024import java.io.InputStream; 025import java.io.OutputStream; 026import java.lang.ProcessBuilder.Redirect; 027import java.nio.file.Path; 028import java.time.Instant; 029import java.util.ArrayList; 030import java.util.Arrays; 031import java.util.Collection; 032import java.util.Collections; 033import java.util.List; 034import java.util.Objects; 035import java.util.function.Function; 036import java.util.stream.Collectors; 037import java.util.stream.Stream; 038import org.jdrupes.builder.api.BuildException; 039import org.jdrupes.builder.api.Cleanliness; 040import org.jdrupes.builder.api.ExecResult; 041import org.jdrupes.builder.api.FileResource; 042import org.jdrupes.builder.api.FileTree; 043import org.jdrupes.builder.api.Project; 044import org.jdrupes.builder.api.Renamable; 045import org.jdrupes.builder.api.RequiredResourceSupport; 046import org.jdrupes.builder.api.Resource; 047import org.jdrupes.builder.api.ResourceProvider; 048import org.jdrupes.builder.api.ResourceRequest; 049import org.jdrupes.builder.api.ResourceType; 050import static org.jdrupes.builder.api.ResourceType.*; 051import org.jdrupes.builder.api.Resources; 052import org.jdrupes.builder.core.AbstractProvider; 053import org.jdrupes.builder.core.StreamCollector; 054 055/// A provider for [execution results][ExecResult] from invoking npm. 056/// The provider generates resources in response to requests for 057/// [ExecResult] where the request's [ResourceRequest#name()] matches 058/// this [provider's name][ResourceProvider#name()]. 059/// 060/// * The working directory is the project directory. 061/// 062/// * The provider first checks if a file `package.json` exists, else it 063/// fails. If no directory `node_modules` exists or `package.json` 064/// is newer than `node_modules/.package-lock.json` it invokes `npm init`. 065/// 066/// * Then, the provider retrieves all resources added by [#required]. While 067/// the provider itself does not process these resources, it is assumed 068/// that they are processed by the `npm` command and therefore need to be 069/// available. 070/// 071/// * If no arguments were specified, the provider returns an [ExecResult] 072/// that indicates successful invocation. The date of the result is set 073/// to the date of `node_modules/.package-lock.json`. 074/// 075/// * The provider invokes the function configured with [#output] and 076/// collects all resources. If the generated resources exist and no 077/// resource from `required` is newer then the generated resources found, 078/// the provider returns a result that indicates successful invocation. 079/// The date of the result is set to the newest date from the generated 080/// resources and the (existing) resources are attached. 081/// 082/// * Else, the provider invokes npm, calls the function set with 083/// `provided` again and adds the result to the [ExecResult] that 084/// it returns. 085/// 086/// The provider also uses the function set with [#output] to determine 087/// the resources to be removed when it is invoked with a request for 088/// [Cleanliness]. 089/// 090/// The generated resources can also be provided directly in response 091/// to a request, see [#provideResources(ResourceRequest)]. 092/// 093/// This provider is made available as an extension. 094/// [ 096/// ](https://mvnrepository.com/artifact/org.jdrupes/jdbld-ext-nodejs) 097/// 098public class NpmExecutor extends AbstractProvider 099 implements Renamable, RequiredResourceSupport { 100 101 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 102 private final Project project; 103 private final List<String> arguments = new ArrayList<>(); 104 private final StreamCollector<Resource> requiredResources 105 = new StreamCollector<>(false); 106 private Function<Project, Stream<Resource>> getOutput 107 = _ -> Stream.empty(); 108 private String nodeJsVersion; 109 private NodeJsDownloader nodeJsDownloader; 110 private ResourceRequest<?> requestForGenerated; 111 112 /// Initializes a new NPM executor. 113 /// 114 /// @param project the project 115 /// 116 public NpmExecutor(Project project) { 117 this.project = project; 118 rename(NpmExecutor.class.getSimpleName() + " in " + project); 119 } 120 121 /// Returns the project that this provider belongs to. 122 /// 123 /// @return the project 124 /// 125 public Project project() { 126 return project; 127 } 128 129 /// Name. 130 /// 131 /// @param name the name 132 /// @return the npm executor 133 /// 134 @Override 135 public NpmExecutor name(String name) { 136 rename(name); 137 return this; 138 } 139 140 /// Sets the node.js version to use. Setting a version is mandatory. 141 /// 142 /// @param version the version 143 /// @return the npm executor 144 /// 145 public NpmExecutor nodeJsVersion(String version) { 146 nodeJsVersion = version; 147 return this; 148 } 149 150 /// Add the given arguments. 151 /// 152 /// @param args the arguments 153 /// @return the npm executor 154 /// 155 public NpmExecutor args(String... args) { 156 arguments.addAll(Arrays.asList(args)); 157 return this; 158 } 159 160 @Override 161 public NpmExecutor required(Stream<? extends Resource> resources) { 162 requiredResources.add(resources); 163 return this; 164 } 165 166 @Override 167 public NpmExecutor required(Path root, String pattern) { 168 requiredResources 169 .add(Stream.of(FileTree.of(project, root, pattern))); 170 return this; 171 } 172 173 @Override 174 public NpmExecutor required(Path root) { 175 requiredResources.add( 176 Stream.of(FileResource.of(project.directory().resolve(root)))); 177 return this; 178 } 179 180 /// Sets the function used to determine the resources generated by this 181 /// provider. 182 /// 183 /// @param resources the resources 184 /// @return the npm executor 185 /// 186 public NpmExecutor output( 187 Function<Project, Stream<Resource>> resources) { 188 this.getOutput = resources; 189 return this; 190 } 191 192 /// Provide the generated resources in response to a request like 193 /// the given one. 194 /// 195 /// @param proto defines the kind of request that the npm executor 196 /// should respond to with the generated resources 197 /// @return the npm executor 198 /// 199 public NpmExecutor provideResources(ResourceRequest<?> proto) { 200 requestForGenerated = proto; 201 return this; 202 } 203 204 @Override 205 @SuppressWarnings({ "PMD.CyclomaticComplexity" }) 206 protected <T extends Resource> Collection<T> 207 doProvide(ResourceRequest<T> request) { 208 if (request.accepts(CleanlinessType)) { 209 getOutput.apply(project).forEach(Resource::cleanup); 210 return Collections.emptyList(); 211 } 212 213 // Handle request for generated resources 214 if (requestForGenerated != null 215 && request.accepts(requestForGenerated.type()) 216 && (requestForGenerated.name().isEmpty() 217 || Objects.equals(requestForGenerated.name().get(), 218 request.name().orElse(null)))) { 219 // No need to evaluate for most special type because 220 // everything is derived from the exec result 221 return provideGenerated(); 222 } 223 224 // Check for and handle request for execution result 225 if (!request.accepts(ExecResultType) 226 || !name().equals(request.name().orElse(null))) { 227 return Collections.emptyList(); 228 } 229 // Always evaluate for the most special type 230 if (!request.type().equals(ExecResultType)) { 231 @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) 232 var result = (Collection<T>) resources(of(ExecResultType) 233 .withName(name())).toList(); 234 return result; 235 } 236 237 // Check prerequisites 238 if (nodeJsVersion == null) { 239 throw new BuildException().from(this) 240 .message("No node.js version specified"); 241 } 242 nodeJsDownloader = new NodeJsDownloader(this, context() 243 .commonCacheDirectory().resolve(getClass().getPackageName())); 244 File packageJson = project.directory().resolve("package.json").toFile(); 245 if (!packageJson.canRead()) { 246 throw new BuildException().from(this) 247 .message("No package.json in %s", project); 248 } 249 File dotPackageLock = project.directory() 250 .resolve("node_modules/.package-lock.json").toFile(); 251 if (!project.directory().resolve("node_modules").toFile().exists() 252 || !dotPackageLock.exists() 253 || packageJson.lastModified() > dotPackageLock.lastModified()) { 254 logger.atConfig().log("Updating node_modules in %s", project); 255 runNpm(project, List.of("install")); 256 } 257 258 // Make sure that the required resources are retrieved and exist 259 var required = Resources.of(new ResourceType<Resources<Resource>>() {}); 260 required.addAll(requiredResources.stream()); 261 if (arguments.isEmpty()) { 262 @SuppressWarnings("unchecked") 263 var result = (T) ExecResult 264 .of(this, "npm install", 0, Stream.empty()) 265 .asOf(Instant.ofEpochMilli(dotPackageLock.lastModified())); 266 return List.of(result); 267 } 268 269 // Get (previously) provided and check if up-to-date 270 var existing = Resources.of(new ResourceType<Resources<Resource>>() {}); 271 existing.addAll(getOutput.apply(project)); 272 if (required.asOf().isPresent() && existing.asOf().isPresent() 273 && !required.asOf().get().isAfter(existing.asOf().get())) { 274 logger.atFine().log("Output from %s is up to date", this); 275 @SuppressWarnings("unchecked") 276 var result = (T) ExecResult.of(this, 277 "existing " + existing.stream().map(Resource::toString) 278 .collect(Collectors.joining(", ")), 279 0, existing.stream()).asOf(existing.asOf().get()); 280 return List.of(result); 281 } 282 return runNpm(project, arguments); 283 } 284 285 private <T extends Resource> Collection<T> runNpm( 286 Project project, List<String> arguments) { 287 var nodeJsExecutable = nodeJsDownloader.npmExecutable(nodeJsVersion); 288 logger.atFine().log("Running %s with %s", this, nodeJsExecutable); 289 List<String> command 290 = new ArrayList<>(List.of(nodeJsExecutable.toString())); 291 command.addAll(arguments); 292 ProcessBuilder processBuilder = new ProcessBuilder(command) 293 .directory(project.directory().toFile()) 294 .redirectInput(Redirect.INHERIT); 295 try { 296 Process process = processBuilder.start(); 297 copyData(process.getInputStream(), context().out()); 298 // npm uses stderr for progress information that we don't 299 // want to marked as error. 300 copyData(process.getErrorStream(), context().out()); 301 int exitValue = process.waitFor(); 302 if (exitValue != 0) { 303 throw new BuildException().from(this) 304 .message("Npm exited with %d", exitValue); 305 } 306 @SuppressWarnings("unchecked") 307 var result = (Collection<T>) List.of(ExecResult.of(this, 308 "[" + project.name() + "]$ npm " 309 + arguments.stream().collect(Collectors.joining(" ")), 310 exitValue, getOutput.apply(project)) 311 .asOf(Instant.now())); 312 return result; 313 } catch (IOException | InterruptedException e) { 314 throw new BuildException().from(this).cause(e); 315 } 316 } 317 318 private void copyData(InputStream source, OutputStream sink) { 319 Thread.startVirtualThread(() -> { 320 try (source) { 321 source.transferTo(sink); 322 } catch (IOException e) { // NOPMD 323 } 324 }); 325 } 326 327 private <T extends Resource> Collection<T> provideGenerated() { 328 // Request execution result 329 var execResult = resources(of(ExecResultType).withName(name())); 330 @SuppressWarnings("unchecked") 331 332 // Extract generated 333 var generated = execResult.map(r -> (Stream<T>) r.resources()) 334 .flatMap(s -> s).toList(); 335 return generated; 336 } 337 338 /// To string. 339 /// 340 /// @return the string 341 /// 342 @Override 343 public String toString() { 344 return super.toString() + "[" + project().name() + "]"; 345 } 346}