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 generated resources can also be provided directly (i.e. not as part 087/// of an [ExecResult]) in response to a configurable resource request, see 088/// [#provideResources(ResourceRequest, Function)]. 089/// 090/// The provider also uses the function set with [#output] to determine 091/// the resources to be removed when it is invoked with a request for 092/// [Cleanliness]. 093/// 094/// This provider is made available as an extension. 095/// [ 097/// ](https://mvnrepository.com/artifact/org.jdrupes/jdbld-ext-nodejs) 098/// 099public class NpmExecutor extends AbstractProvider 100 implements Renamable, RequiredResourceSupport { 101 102 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 103 private final Project project; 104 private final List<String> arguments = new ArrayList<>(); 105 private final StreamCollector<Resource> requiredResources 106 = new StreamCollector<>(true); 107 private Function<Project, Stream<Resource>> getOutput 108 = _ -> Stream.empty(); 109 private String nodeJsVersion; 110 private NodeJsDownloader nodeJsDownloader; 111 private ResourceRequest<?> requestForGenerated; 112 113 /// Initializes a new NPM executor. 114 /// 115 /// @param project the project 116 /// 117 public NpmExecutor(Project project) { 118 this.project = project; 119 rename(NpmExecutor.class.getSimpleName() + " in " + project); 120 } 121 122 /// Returns the project that this provider belongs to. 123 /// 124 /// @return the project 125 /// 126 public Project project() { 127 return project; 128 } 129 130 @Override 131 public NpmExecutor name(String name) { 132 rename(name); 133 return this; 134 } 135 136 /// Sets the node.js version to use. Setting a version is mandatory. 137 /// 138 /// @param version the version 139 /// @return the npm executor 140 /// 141 public NpmExecutor nodeJsVersion(String version) { 142 nodeJsVersion = version; 143 return this; 144 } 145 146 /// Add the given arguments. 147 /// 148 /// @param args the arguments 149 /// @return the npm executor 150 /// 151 public NpmExecutor args(String... args) { 152 arguments.addAll(Arrays.asList(args)); 153 return this; 154 } 155 156 @Override 157 public NpmExecutor required(Stream<? extends Resource> resources) { 158 requiredResources.add(resources); 159 return this; 160 } 161 162 @Override 163 public NpmExecutor required(Path root, String pattern) { 164 requiredResources 165 .add(Stream.of(FileTree.of(project, root, pattern))); 166 return this; 167 } 168 169 @Override 170 public NpmExecutor required(Path file) { 171 requiredResources.add( 172 Stream.of(FileResource.of(project.directory().resolve(file)))); 173 return this; 174 } 175 176 /// Sets the function used to determine the resources generated by this 177 /// provider. 178 /// 179 /// @param resources the resources 180 /// @return the npm executor 181 /// 182 public NpmExecutor output( 183 Function<Project, Stream<Resource>> resources) { 184 this.getOutput = resources; 185 return this; 186 } 187 188 /// Provide the generated resources directly in response to a requests 189 /// like the given prototype request. Invoking this method implies a 190 /// call to [#output(Function)] with `resources`. 191 /// 192 /// @param proto defines the kind of request that the script executor 193 /// should respond to with the generated resources 194 /// @param resources the function that provides the results as resources 195 /// @return the script executor 196 /// 197 public NpmExecutor provideResources(ResourceRequest<?> proto, 198 Function<Project, Stream<Resource>> resources) { 199 requestForGenerated = proto; 200 return output(resources); 201 } 202 203 @Override 204 @SuppressWarnings({ "PMD.CyclomaticComplexity" }) 205 protected <T extends Resource> Collection<T> 206 doProvide(ResourceRequest<T> request) { 207 if (request.accepts(CleanlinessType)) { 208 getOutput.apply(project).forEach(Resource::cleanup); 209 return Collections.emptyList(); 210 } 211 212 // Handle request for generated resources 213 if (requestForGenerated != null 214 && request.accepts(requestForGenerated.type()) 215 && (requestForGenerated.name().isEmpty() 216 || Objects.equals(requestForGenerated.name().get(), 217 request.name().orElse(null)))) { 218 // No need to evaluate for most special type because 219 // everything is derived from the exec result 220 return provideGenerated(); 221 } 222 223 // Check for and handle request for execution result 224 if (!request.accepts(ExecResultType) 225 || !name().equals(request.name().orElse(null))) { 226 return Collections.emptyList(); 227 } 228 // Always evaluate for the most special type 229 if (!request.type().equals(ExecResultType)) { 230 @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) 231 var result = (Collection<T>) resources(of(ExecResultType) 232 .withName(name())).toList(); 233 return result; 234 } 235 236 // Check prerequisites 237 if (nodeJsVersion == null) { 238 throw new BuildException().from(this) 239 .message("No node.js version specified"); 240 } 241 nodeJsDownloader = new NodeJsDownloader(this, context() 242 .commonCacheDirectory().resolve(getClass().getPackageName())); 243 File packageJson = project.directory().resolve("package.json").toFile(); 244 if (!packageJson.canRead()) { 245 throw new BuildException().from(this) 246 .message("No package.json in %s", project); 247 } 248 File dotPackageLock = project.directory() 249 .resolve("node_modules/.package-lock.json").toFile(); 250 if (!project.directory().resolve("node_modules").toFile().exists() 251 || !dotPackageLock.exists() 252 || packageJson.lastModified() > dotPackageLock.lastModified()) { 253 logger.atConfig().log("Updating node_modules in %s", project); 254 runNpm(project, List.of("install")); 255 } 256 257 // Make sure that the required resources are retrieved and exist 258 var required = Resources.of(new ResourceType<Resources<Resource>>() {}); 259 required.addAll(requiredResources.stream()); 260 if (arguments.isEmpty()) { 261 @SuppressWarnings("unchecked") 262 var result = (T) ExecResult 263 .of(this, "npm install", 0, Stream.empty()) 264 .asOf(Instant.ofEpochMilli(dotPackageLock.lastModified())); 265 return List.of(result); 266 } 267 268 // Get (previously) provided and check if up-to-date 269 var existing = Resources.of(new ResourceType<Resources<Resource>>() {}); 270 existing.addAll(getOutput.apply(project)); 271 if (required.asOf().isPresent() && existing.asOf().isPresent() 272 && !required.asOf().get().isAfter(existing.asOf().get())) { 273 logger.atFine().log("Output from %s is up to date", this); 274 @SuppressWarnings("unchecked") 275 var result = (T) ExecResult.of(this, 276 "existing " + existing.stream().map(Resource::toString) 277 .collect(Collectors.joining(", ")), 278 0, existing.stream()).asOf(existing.asOf().get()); 279 return List.of(result); 280 } 281 return runNpm(project, arguments); 282 } 283 284 private <T extends Resource> Collection<T> runNpm( 285 Project project, List<String> arguments) { 286 var nodeJsExecutable = nodeJsDownloader.npmExecutable(nodeJsVersion); 287 logger.atFine().log("Running %s with %s", this, nodeJsExecutable); 288 List<String> command 289 = new ArrayList<>(List.of(nodeJsExecutable.toString())); 290 command.addAll(arguments); 291 ProcessBuilder processBuilder = new ProcessBuilder(command) 292 .directory(project.directory().toFile()) 293 .redirectInput(Redirect.INHERIT); 294 try { 295 Process process = processBuilder.start(); 296 copyData(process.getInputStream(), context().out()); 297 // npm uses stderr for progress information that we don't 298 // want to marked as error. 299 copyData(process.getErrorStream(), context().out()); 300 int exitValue = process.waitFor(); 301 if (exitValue != 0) { 302 throw new BuildException().from(this) 303 .message("Npm exited with %d", exitValue); 304 } 305 @SuppressWarnings("unchecked") 306 var result = (Collection<T>) List.of(ExecResult.of(this, 307 "[" + project.name() + "]$ npm " 308 + arguments.stream().collect(Collectors.joining(" ")), 309 exitValue, getOutput.apply(project)) 310 .asOf(Instant.now())); 311 return result; 312 } catch (IOException | InterruptedException e) { 313 throw new BuildException().from(this).cause(e); 314 } 315 } 316 317 private void copyData(InputStream source, OutputStream sink) { 318 Thread.startVirtualThread(() -> { 319 try (source) { 320 source.transferTo(sink); 321 } catch (IOException e) { // NOPMD 322 } 323 }); 324 } 325 326 private <T extends Resource> Collection<T> provideGenerated() { 327 // Request execution result 328 var execResult = resources(of(ExecResultType).withName(name())); 329 @SuppressWarnings("unchecked") 330 331 // Extract generated 332 var generated = execResult.map(r -> (Stream<T>) r.resources()) 333 .flatMap(s -> s).toList(); 334 return generated; 335 } 336 337 @Override 338 public String toString() { 339 return super.toString() + "[" + project().name() + "]"; 340 } 341}