From 83424c334c848456d229a5975354cb2f12b60047 Mon Sep 17 00:00:00 2001 From: David Smiley Date: Thu, 2 Jul 2026 14:12:17 -0400 Subject: [PATCH] build: decouple JS API client bundling into an optional module The OpenAPI-generated JS client (npm install + babel build + browserify bundle) was embedded directly in :solr:webapp's build.gradle, making that module quietly depend on npm/node for something unrelated to compiling/packaging the webapp itself. Pull it out into its own module, :solr:webapp:js-client, which exposes the built bundle via a `jsClientBundle` configuration that :solr:webapp now consumes as a normal cross-project dependency (mirroring how it already consumes :solr:server's serverLib/solrCore and :solr:api's jsClient outputs), instead of :solr:webapp defining the npm tasks and reaching into its own local task outputs. The new module is written in Kotlin DSL (build.gradle.kts) rather than Groovy, because Kotlin DSL's static typing advantages. It avoided a bug or two! Also make the JS client bundle optional, mirroring the existing disableUiModule pattern: - settings.gradle: new -PdisableJsClient=true flag (gradle.ext.withJsClient), excluding :solr:webapp:js-client from the build entirely when set. - solr/webapp/build.gradle: the generatedJSClientBundle dependency is only added when the flag is on; the war{} block's from(configurations.generatedJSClientBundle) is left unconditional since an empty configuration is already a safe no-op. - build.gradle: mirrors gradle.ext.withJsClient into rootProject.ext, matching the existing withUiModule convention. Refactor gradle/node.gradle so it no longer hardcodes which projects need node/npm. Previously it named :solr:solr-ref-guide and :solr:webapp explicitly; now those projects apply the node-gradle plugin themselves in their own build.gradle(.kts), and gradle/node.gradle just reacts via `allprojects { pluginManager.withPlugin(...) { ... } }` to configure whichever projects end up applying it (proxy/registry override, pinned node version, .gradle/node workdir layout). This makes "does this module need node?" answerable by reading the module's own build file instead of a central list, and removes the maintenance burden of keeping that list in sync. Verified end-to-end: default build (`:solr:webapp:war`) produces an identical war to before (same libs/solr/{README.md,docs/,index.js} contents); with -PdisableJsClient=true the war omits libs/solr entirely and zero npm/node tasks run; :solr:solr-ref-guide still exposes its node/npm tasks after self-applying the plugin. --- build.gradle | 1 + gradle/node.gradle | 67 +++++++------- settings.gradle | 6 ++ solr/solr-ref-guide/build.gradle | 1 + solr/webapp/build.gradle | 91 ++----------------- solr/webapp/gradle.lockfile | 2 +- solr/webapp/js-client/build.gradle.kts | 119 +++++++++++++++++++++++++ solr/webapp/js-client/gradle.lockfile | 5 ++ 8 files changed, 174 insertions(+), 118 deletions(-) create mode 100644 solr/webapp/js-client/build.gradle.kts create mode 100644 solr/webapp/js-client/gradle.lockfile diff --git a/build.gradle b/build.gradle index b82fdbbdcad2..efa9a2ca69c2 100644 --- a/build.gradle +++ b/build.gradle @@ -132,6 +132,7 @@ ext { // Use gradle flag in root project for further referencing withUiModule = gradle.ext.withUiModule + withJsClient = gradle.ext.withJsClient } // Include smaller chunks configuring dedicated build areas. diff --git a/gradle/node.gradle b/gradle/node.gradle index 0cf7923bac09..5934db87fdd4 100644 --- a/gradle/node.gradle +++ b/gradle/node.gradle @@ -15,47 +15,50 @@ * limitations under the License. */ -configure([project(":solr:solr-ref-guide"), project(":solr:webapp")]) { - apply plugin: libs.plugins.nodegradle.node.get().pluginId +// Projects that need node/npm apply the node-gradle plugin themselves; this shared +// config reacts to whichever projects do so, rather than naming them explicitly. +allprojects { + pluginManager.withPlugin(libs.plugins.nodegradle.node.get().pluginId) { - def npmRegistry = "${-> propertyOrEnvOrDefault("solr.npm.registry", "SOLR_NPM_REGISTRY", '')}" - if (!npmRegistry.isEmpty()) { - tasks.npmSetup { - args.addAll(['--registry', npmRegistry]) - } - - afterEvaluate { - tasks.withType(NpmTask).each {npmTask -> - npmTask.environment.put('NPM_CONFIG_REGISTRY', npmRegistry) + def npmRegistry = "${-> propertyOrEnvOrDefault("solr.npm.registry", "SOLR_NPM_REGISTRY", '')}" + if (!npmRegistry.isEmpty()) { + tasks.npmSetup { + args.addAll(['--registry', npmRegistry]) } - tasks.withType(NpxTask).each {npxTask -> - npxTask.environment.put('NPM_CONFIG_REGISTRY', npmRegistry) + + afterEvaluate { + tasks.withType(NpmTask).each {npmTask -> + npmTask.environment.put('NPM_CONFIG_REGISTRY', npmRegistry) + } + tasks.withType(NpxTask).each {npxTask -> + npxTask.environment.put('NPM_CONFIG_REGISTRY', npmRegistry) + } } } - } - project.ext { - nodeProjectDir = layout.projectDirectory.dir(".gradle/node") - } + project.ext { + nodeProjectDir = layout.projectDirectory.dir(".gradle/node") + } - node { - download = true - version = libs.versions.nodejs.get() + node { + download = true + version = libs.versions.nodejs.get() - def nodeDistUrl = "${-> propertyOrEnvOrDefault("solr.node.distUrl", "SOLR_NODE_DIST_URL", '')}" - if (!nodeDistUrl.isEmpty()) { - distBaseUrl = nodeDistUrl - } + def nodeDistUrl = "${-> propertyOrEnvOrDefault("solr.node.distUrl", "SOLR_NODE_DIST_URL", '')}" + if (!nodeDistUrl.isEmpty()) { + distBaseUrl = nodeDistUrl + } - // The directory where Node.js is unpacked (when download is true) - workDir = file("${project.ext.nodeProjectDir.getAsFile().path}/nodejs") + // The directory where Node.js is unpacked (when download is true) + workDir = file("${project.ext.nodeProjectDir.getAsFile().path}/nodejs") - // The directory where npm is installed (when a specific version is defined) - npmWorkDir = file("${project.ext.nodeProjectDir.getAsFile().path}/npm") + // The directory where npm is installed (when a specific version is defined) + npmWorkDir = file("${project.ext.nodeProjectDir.getAsFile().path}/npm") - // The Node.js project directory location - // This is where the package.json file and node_modules directory are located - // By default it is at the root of the current project - nodeProjectDir = project.ext.nodeProjectDir + // The Node.js project directory location + // This is where the package.json file and node_modules directory are located + // By default it is at the root of the current project + nodeProjectDir = project.ext.nodeProjectDir + } } } diff --git a/settings.gradle b/settings.gradle index 782edec43251..39f8609f6365 100644 --- a/settings.gradle +++ b/settings.gradle @@ -74,5 +74,11 @@ if (gradle.ext.withUiModule) { include(":solr:ui") } +def disableJsClientValue = providers.gradleProperty('disableJsClient').orNull +gradle.ext.withJsClient = disableJsClientValue == null || disableJsClientValue != 'true' +if (gradle.ext.withJsClient) { + include(":solr:webapp:js-client") +} + // Configures development for joint Lucene/ Solr composite build. apply from: file('gradle/lucene-dev/lucene-dev-repo-composite.gradle') diff --git a/solr/solr-ref-guide/build.gradle b/solr/solr-ref-guide/build.gradle index 66f4437249b8..cf95613fecb1 100644 --- a/solr/solr-ref-guide/build.gradle +++ b/solr/solr-ref-guide/build.gradle @@ -19,6 +19,7 @@ import groovy.json.JsonOutput import org.apache.tools.ant.util.TeeOutputStream apply plugin: 'java' +apply plugin: libs.plugins.nodegradle.node.get().pluginId description = 'Solr Reference Guide' diff --git a/solr/webapp/build.gradle b/solr/webapp/build.gradle index 62d6a1305629..6ca3fcc61759 100644 --- a/solr/webapp/build.gradle +++ b/solr/webapp/build.gradle @@ -26,14 +26,10 @@ configurations { war {} serverLib solrCore - generatedJSClient generatedJSClientBundle -} - -ext { - jsClientWorkspace = layout.buildDirectory.dir("jsClientWorkspace").get() - jsClientBuildDir = layout.buildDirectory.dir("jsClientBuild").get() - jsClientBundleDir = layout.buildDirectory.dir("jsClientBundle").get() + // TODO: :solr:ui's dev/prod distributions are pulled in via raw cross-project task + // references (see generateUiDevFiles/generateUiProdFiles below) instead of a + // configuration like this one; consider exposing them as a real configuration too. } dependencies { @@ -42,57 +38,11 @@ dependencies { solrCore project(":solr:core") implementation(configurations.solrCore - configurations.serverLib) - generatedJSClient project(path: ":solr:api", configuration: "jsClient") - generatedJSClientBundle files(jsClientBundleDir) { - builtBy "finalizeJsBundleDir" - } -} - -task syncJSClientSourceCode(type: Sync) { - group = 'Solr JS Client' - from configurations.generatedJSClient - - into jsClientWorkspace - - // Keep the node modules, so that they don't need to be re-downloaded - preserve { - include "node_modules/**" + if (gradle.ext.withJsClient) { + generatedJSClientBundle project(path: ":solr:webapp:js-client", configuration: "jsClientBundle") } } -task jsClientDownloadDeps(type: NpmTask) { - group = 'Solr JS Client' - dependsOn tasks.syncJSClientSourceCode - - args = ["install"] - workingDir = file(jsClientWorkspace) - - inputs.dir("${jsClientWorkspace}/src") - inputs.file("${jsClientWorkspace}/package.json") - outputs.dir("${jsClientWorkspace}/node_modules") -} - -task jsClientBuild(type: NpmTask) { - group = 'Solr JS Client' - dependsOn tasks.jsClientDownloadDeps - - args = ["run", "build"] - workingDir = file(jsClientWorkspace) - - inputs.dir("${jsClientWorkspace}/src") - inputs.file("${jsClientWorkspace}/package.json") - inputs.dir("${jsClientWorkspace}/node_modules") - outputs.dir("${jsClientWorkspace}/dist") -} - -task downloadBrowserify(type: NpmTask) { - group = 'Build Dependency Download' - args = ["install", "browserify@${libs.versions.browserify.get()}"] - - inputs.property("browserify version", libs.versions.browserify.get()) - outputs.dir("${nodeProjectDir}/node_modules/browserify") -} - if (gradle.ext.withUiModule) { tasks.register("generateUiDevFiles") { description = "Generate new UI for development and add files to outputs for later referencing." @@ -113,39 +63,10 @@ if (gradle.ext.withUiModule) { } } -task generateJsClientBundle(type: NpxTask) { - group = 'Solr JS Client' - dependsOn tasks.downloadBrowserify - dependsOn tasks.jsClientBuild - - command = 'browserify' - args = ['dist/index.js', '-s', 'solrApi', '-o', "${jsClientBuildDir}/index.js"] - workingDir = file(jsClientWorkspace) - - inputs.dir(jsClientWorkspace) - inputs.property("browserify version", libs.versions.browserify.get()) - - outputs.file("${jsClientBuildDir}/index.js") -} - -task finalizeJsBundleDir(type: Sync) { - group = 'Solr JS Client' - - from configurations.generatedJSClient { - include "README.md" - include "docs/**" - } - - from tasks.generateJsClientBundle { - include "index.js" - } - - into jsClientBundleDir -} - war { from("web") + // note: nonetheless may be disabled, copying nothing from(configurations.generatedJSClientBundle, { into "libs/solr" }) diff --git a/solr/webapp/gradle.lockfile b/solr/webapp/gradle.lockfile index fec92697f635..fd5c99e1d63b 100644 --- a/solr/webapp/gradle.lockfile +++ b/solr/webapp/gradle.lockfile @@ -158,4 +158,4 @@ org.slf4j:jcl-over-slf4j:2.0.17=serverLib,solrCore org.slf4j:jul-to-slf4j:2.0.17=serverLib org.slf4j:slf4j-api:2.0.17=serverLib,solrCore org.xerial.snappy:snappy-java:1.1.10.8=solrCore -empty=compileClasspath,generatedJSClient,generatedJSClientBundle,jarValidation,missingdoclet,providedCompile,providedRuntime,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,war +empty=compileClasspath,generatedJSClientBundle,jarValidation,missingdoclet,providedCompile,providedRuntime,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,war diff --git a/solr/webapp/js-client/build.gradle.kts b/solr/webapp/js-client/build.gradle.kts new file mode 100644 index 000000000000..1ce43c25978b --- /dev/null +++ b/solr/webapp/js-client/build.gradle.kts @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.github.gradle.node.npm.task.NpmTask +import com.github.gradle.node.npm.task.NpxTask + +// Builds the OpenAPI-generated JS client (from :solr:api) into a single bundled +// file, for :solr:webapp to include in the war. This is the only place in the +// build that needs npm/node for this purpose; it can be disabled entirely via +// -PdisableJsClient=true (see settings.gradle). + +plugins { + id("base") + alias(libs.plugins.nodegradle.node) +} + +description = "Generates a JavaScript client for Solr OpenApi" + +val jsClientWorkspace = layout.buildDirectory.dir("jsClientWorkspace").get().asFile +val jsClientBuildDir = layout.buildDirectory.dir("jsClientBuild").get().asFile +val jsClientBundleDir = layout.buildDirectory.dir("jsClientBundle").get().asFile + +val generatedJSClient = configurations.create("generatedJSClient") +val jsClientBundle = configurations.create("jsClientBundle") { + isCanBeConsumed = true + isCanBeResolved = false +} + +dependencies { + generatedJSClient(project(path = ":solr:api", configuration = "jsClient")) +} + +val syncJSClientSourceCode = tasks.register("syncJSClientSourceCode") { + from(generatedJSClient) + + into(jsClientWorkspace) + + // Keep the node modules, so that they don't need to be re-downloaded + preserve { + include("node_modules/**") + } +} + +val jsClientDownloadDeps = tasks.register("jsClientDownloadDeps") { + dependsOn(syncJSClientSourceCode) + + args.set(listOf("install")) + workingDir.set(jsClientWorkspace) + + inputs.dir("$jsClientWorkspace/src") + inputs.file("$jsClientWorkspace/package.json") + outputs.dir("$jsClientWorkspace/node_modules") +} + +val jsClientBuild = tasks.register("jsClientBuild") { + dependsOn(jsClientDownloadDeps) + + args.set(listOf("run", "build")) + workingDir.set(jsClientWorkspace) + + inputs.dir("$jsClientWorkspace/src") + inputs.file("$jsClientWorkspace/package.json") + inputs.dir("$jsClientWorkspace/node_modules") + outputs.dir("$jsClientWorkspace/dist") +} + +val downloadBrowserify = tasks.register("downloadBrowserify") { + args.set(listOf("install", "browserify@${libs.versions.browserify.get()}")) + + inputs.property("browserify version", libs.versions.browserify.get()) + outputs.dir(project.extra["nodeProjectDir"].toString() + "/node_modules/browserify") +} + +val generateJsClientBundle = tasks.register("generateJsClientBundle") { + dependsOn(downloadBrowserify) + dependsOn(jsClientBuild) + + command.set("browserify") + args.set(listOf("dist/index.js", "-s", "solrApi", "-o", "$jsClientBuildDir/index.js")) + workingDir.set(jsClientWorkspace) + + inputs.dir(jsClientWorkspace) + inputs.property("browserify version", libs.versions.browserify.get()) + + outputs.file("$jsClientBuildDir/index.js") +} + +val finalizeJsBundleDir = tasks.register("finalizeJsBundleDir") { + from(generatedJSClient) { + include("README.md") + include("docs/**") + } + + from(generateJsClientBundle) { + include("index.js") + } + + into(jsClientBundleDir) +} + +artifacts { + add("jsClientBundle", jsClientBundleDir) { + builtBy(finalizeJsBundleDir) + } +} diff --git a/solr/webapp/js-client/gradle.lockfile b/solr/webapp/js-client/gradle.lockfile new file mode 100644 index 000000000000..3a4ae5a9eaa5 --- /dev/null +++ b/solr/webapp/js-client/gradle.lockfile @@ -0,0 +1,5 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :solr:webapp:js-client:dependencies --write-locks +empty=generatedJSClient,jarValidation