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