Skip to content

Commit f6dd317

Browse files
authored
fix: expose production frontend bundle on the runtime classpath (#25024)
The production frontend bundle produced by vaadinBuildFrontend was written to a task-owned directory (build/vaadin-build-frontend) that is not on the runtime classpath. It was copied into application archives with an explicit Jar.from(...), but never exposed on the classpath, so an application served in place from the source set output - gretty during a production build, or an IDE/bootRun launch - no longer found the bundle or the production flow-build-info.json and started in development mode, failing with "There is no dev-bundle ... found". Register the bundle directory as an extra output of the main source set (output.dir(builtBy: vaadinBuildFrontend)), restoring the behavior that shipped through 25.1.x: the bundle is on the runtime classpath for in-place runs and is packaged into every application archive through standard source-set-output handling (WEB-INF/classes for WAR, the archive root for plain and Spring Boot executable jars). The task-owned output directory introduced in #25001 is kept, so the build-cache fix (#24012) is preserved. Because output.dir makes the `classes` lifecycle task depend on vaadinBuildFrontend, this task no longer depends on `classes` (which would form a cycle). Ordering after compilation is established by its @classpath classesDirs input, and ordering after resource processing by an explicit dependency on the processResources task. The per-archive Jar.from(...) copying, the Spring Boot BOOT-INF/classes special-casing, and the includeArchiveTasks/excludeArchiveTasks options (all introduced in 25.3.0-alpha4, never in a stable release) are no longer needed and are removed. The Spring Boot bundle location returns to the archive root, matching 25.1.x. Fixes #25021
1 parent 6d7bf86 commit f6dd317

5 files changed

Lines changed: 96 additions & 186 deletions

File tree

flow-plugins/flow-gradle-plugin/src/functionalTest/kotlin/com/vaadin/gradle/MiscSingleModuleTest.kt

Lines changed: 43 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,12 +1015,10 @@ class MiscSingleModuleTest : AbstractGradleTest() {
10151015
}
10161016

10171017
/**
1018-
* Before the frontend output was moved to a task-owned directory, the
1019-
* production bundle was written into the `main` source set resources, so
1020-
* every archive task - including custom-named `Jar` tasks - triggered
1021-
* `vaadinBuildFrontend` and packaged the bundle. Restricting packaging to
1022-
* a fixed set of well-known task names must not silently drop the bundle
1023-
* (and the `vaadinBuildFrontend` dependency) from such custom archives.
1018+
* A custom-named archive task that packages the `main` source set output
1019+
* receives the production bundle (registered as an extra source set output
1020+
* directory) and transitively depends on `vaadinBuildFrontend` through the
1021+
* `builtBy` wiring, without any explicit task dependency.
10241022
*/
10251023
@Test
10261024
fun buildFrontend_customArchiveTask_containsProductionBundle() {
@@ -1058,9 +1056,10 @@ class MiscSingleModuleTest : AbstractGradleTest() {
10581056
}
10591057

10601058
/**
1061-
* Source and javadoc archives are identified by their classifier and
1062-
* must never carry the production frontend bundle, even though they are
1063-
* `Jar` tasks.
1059+
* The production bundle is exposed as an extra `main` source set output
1060+
* directory, so it is packaged only into archives that include the source
1061+
* set output. Source and javadoc jars package `allSource`/javadoc output
1062+
* instead, so they must never carry the production frontend bundle.
10641063
*/
10651064
@Test
10661065
fun buildFrontend_sourcesAndJavadocJars_doNotContainProductionBundle() {
@@ -1108,89 +1107,62 @@ class MiscSingleModuleTest : AbstractGradleTest() {
11081107
}
11091108

11101109
/**
1111-
* A custom archive that would be packaged by default can be opted out
1112-
* via `vaadin.excludeArchiveTasks`. The frontend is still built (the
1113-
* `jar` task consumes it), but the excluded archive stays frontend-free.
1114-
*/
1115-
@Test
1116-
fun buildFrontend_excludedArchiveTask_doesNotContainProductionBundle() {
1117-
testProject.buildFile.writeText(
1118-
"""
1119-
plugins {
1120-
id 'java'
1121-
id("com.vaadin.flow")
1122-
}
1123-
repositories {
1124-
mavenLocal()
1125-
mavenCentral()
1126-
maven { url = 'https://maven.vaadin.com/vaadin-prereleases' }
1127-
}
1128-
vaadin {
1129-
excludeArchiveTasks = ['appJar']
1130-
}
1131-
def jettyVersion = "11.0.12"
1132-
dependencies {
1133-
implementation("com.vaadin:flow:$flowVersion")
1134-
implementation("org.slf4j:slf4j-simple:$slf4jVersion")
1135-
implementation("jakarta.servlet:jakarta.servlet-api:6.0.0")
1136-
implementation("org.eclipse.jetty:jetty-server:${"$"}{jettyVersion}")
1137-
implementation("org.eclipse.jetty.websocket:websocket-jakarta-server:${"$"}{jettyVersion}")
1138-
}
1139-
tasks.register('appJar', Jar) {
1140-
archiveClassifier = 'app'
1141-
from sourceSets.main.output
1142-
}
1143-
""".trimIndent()
1144-
)
1145-
1146-
val result = testProject.build("-Pvaadin.productionMode", "build", "appJar")
1147-
result.expectTaskSucceded("vaadinBuildFrontend")
1148-
1149-
val appJar = testProject.folder("build/libs").find("*-app.jar").first()
1150-
expectArchiveDoesntContainVaadinBundle(appJar, false)
1151-
}
1152-
1153-
/**
1154-
* A `tests`-classified archive is excluded by default, but can be forced
1155-
* to receive the frontend bundle via `vaadin.includeArchiveTasks`, which
1156-
* overrides the classifier-based exclusion.
1110+
* The production bundle is registered as an extra `main` source set output
1111+
* directory, so it is on the runtime classpath. This is what lets an
1112+
* application served in place during a production build (e.g. gretty
1113+
* integration tests) run in production mode instead of falling back to the
1114+
* development bundle. Regression test for the bundle being written to a
1115+
* task-owned directory that was not on the runtime classpath.
1116+
*
1117+
* The check uses `stats.json` rather than `flow-build-info.json` because
1118+
* the token is intentionally removed from the bundle directory after the
1119+
* build and only restored while an application archive is being packaged;
1120+
* `stats.json` is a stable marker of the bundle directory's presence on the
1121+
* classpath.
11571122
*/
11581123
@Test
1159-
fun buildFrontend_includedArchiveTask_overridesClassifierExclusion() {
1124+
fun buildFrontend_productionBundle_isOnMainRuntimeClasspath() {
11601125
testProject.buildFile.writeText(
11611126
"""
11621127
plugins {
1163-
id 'java'
1128+
id 'war'
1129+
id 'org.gretty' version '4.0.3'
11641130
id("com.vaadin.flow")
11651131
}
11661132
repositories {
11671133
mavenLocal()
11681134
mavenCentral()
11691135
maven { url = 'https://maven.vaadin.com/vaadin-prereleases' }
11701136
}
1171-
vaadin {
1172-
includeArchiveTasks = ['fixtureJar']
1173-
}
1174-
def jettyVersion = "11.0.12"
11751137
dependencies {
11761138
implementation("com.vaadin:flow:$flowVersion")
1139+
providedCompile("jakarta.servlet:jakarta.servlet-api:6.0.0")
11771140
implementation("org.slf4j:slf4j-simple:$slf4jVersion")
1178-
implementation("jakarta.servlet:jakarta.servlet-api:6.0.0")
1179-
implementation("org.eclipse.jetty:jetty-server:${"$"}{jettyVersion}")
1180-
implementation("org.eclipse.jetty.websocket:websocket-jakarta-server:${"$"}{jettyVersion}")
11811141
}
1182-
tasks.register('fixtureJar', Jar) {
1183-
archiveClassifier = 'tests'
1184-
from sourceSets.main.output
1142+
tasks.register('verifyBundleOnRuntimeClasspath') {
1143+
dependsOn 'vaadinBuildFrontend'
1144+
def runtimeClasspath = sourceSets.main.runtimeClasspath
1145+
doLast {
1146+
def stats = runtimeClasspath.files.collect {
1147+
new File(it, 'META-INF/VAADIN/config/stats.json')
1148+
}.find { it.exists() }
1149+
if (stats == null) {
1150+
throw new GradleException(
1151+
'production bundle (META-INF/VAADIN/config/stats.json) ' +
1152+
'not found on main runtime classpath')
1153+
}
1154+
println 'PRODUCTION_BUNDLE_ON_CLASSPATH=' + stats
1155+
}
11851156
}
11861157
""".trimIndent()
11871158
)
11881159

1189-
val result = testProject.build("-Pvaadin.productionMode", "fixtureJar")
1160+
val result = testProject.build(
1161+
"-Pvaadin.productionMode", "verifyBundleOnRuntimeClasspath")
11901162
result.expectTaskSucceded("vaadinBuildFrontend")
1191-
1192-
val fixtureJar = testProject.folder("build/libs").find("*-tests.jar").first()
1193-
expectArchiveContainsVaadinBundle(fixtureJar, false)
1163+
expect(true) {
1164+
result.output.contains("PRODUCTION_BUNDLE_ON_CLASSPATH=")
1165+
}
11941166
}
11951167

11961168
@Test

flow-plugins/flow-gradle-plugin/src/functionalTest/kotlin/com/vaadin/gradle/TestUtils.kt

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,10 @@ fun expectArchiveContainsVaadinBundle(
151151
) {
152152
val isWar: Boolean = archive.name.endsWith(".war", true)
153153
val isStandaloneJar: Boolean = !isWar && !isSpringBootJar
154-
val resourcePackaging: String = when {
155-
isWar -> "WEB-INF/classes/"
156-
isSpringBootJar -> "BOOT-INF/classes/"
157-
else -> ""
158-
}
154+
// The bundle is packaged as source set output: a War routes it to
155+
// WEB-INF/classes, while a plain or Spring Boot executable jar keeps it at
156+
// the archive root. (Spring Boot dependency jars still go to BOOT-INF/lib.)
157+
val resourcePackaging: String = if (isWar) "WEB-INF/classes/" else ""
159158
expectArchiveContains(
160159
"${resourcePackaging}META-INF/VAADIN/config/flow-build-info.json",
161160
"${resourcePackaging}META-INF/VAADIN/config/stats.json",
@@ -186,11 +185,9 @@ fun expectArchiveDoesntContainVaadinBundle(archive: File,
186185
isSpringBootJar: Boolean) {
187186
val isWar: Boolean = archive.name.endsWith(".war", true)
188187
val isStandaloneJar: Boolean = !isWar && !isSpringBootJar
189-
val resourcePackaging: String = when {
190-
isWar -> "WEB-INF/classes/"
191-
isSpringBootJar -> "BOOT-INF/classes/"
192-
else -> ""
193-
}
188+
// See expectArchiveContainsVaadinBundle: War → WEB-INF/classes, plain and
189+
// Spring Boot executable jars keep the bundle at the archive root.
190+
val resourcePackaging: String = if (isWar) "WEB-INF/classes/" else ""
194191
expectArchiveDoesntContain("${resourcePackaging}META-INF/VAADIN/config/flow-build-info.json",
195192
"${resourcePackaging}META-INF/VAADIN/config/stats.json",
196193
"${resourcePackaging}META-INF/VAADIN/webapp/VAADIN/build/*.gz",

flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/FlowPlugin.kt

Lines changed: 26 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import org.gradle.api.Plugin
2121
import org.gradle.api.Project
2222
import org.gradle.api.plugins.JavaPlugin
2323
import org.gradle.api.tasks.bundling.Jar
24-
import org.gradle.api.tasks.bundling.War
2524
import org.gradle.util.GradleVersion
2625

2726
/**
@@ -31,12 +30,6 @@ import org.gradle.util.GradleVersion
3130
public class FlowPlugin : Plugin<Project> {
3231
public companion object {
3332
public const val GRADLE_MINIMUM_SUPPORTED_VERSION: String = "8.14"
34-
35-
// Archive classifiers that never carry the production frontend
36-
// bundle (source and javadoc distributions, test fixtures). Any
37-
// other Jar/War/BootJar is treated as an application archive.
38-
private val NON_APPLICATION_ARCHIVE_CLASSIFIERS =
39-
setOf("sources", "javadoc", "test-sources", "tests")
4033
}
4134

4235
override fun apply(project: Project) {
@@ -86,29 +79,25 @@ public class FlowPlugin : Plugin<Project> {
8679
val vaadinBuildFrontendOutputDirectory =
8780
vaadinServletResourcesDirectory.parentFile?.parentFile
8881

89-
val sourceSetResourcesDirectory =
90-
project.getBuildResourcesDir(config.sourceSetName.get())
91-
92-
val includedArchiveTasks = config.includeArchiveTasks.get().toSet()
93-
val excludedArchiveTasks = config.excludeArchiveTasks.get().toSet()
94-
95-
project.tasks.withType(Jar::class.java) { task: Jar ->
96-
if (task.isVaadinApplicationArchiveTask(
97-
includedArchiveTasks, excludedArchiveTasks)) {
98-
task.dependsOn("vaadinBuildFrontend")
99-
if (vaadinBuildFrontendOutputDirectory != null &&
100-
vaadinBuildFrontendOutputDirectory.canonicalFile !=
101-
sourceSetResourcesDirectory.canonicalFile
102-
) {
103-
task.from(vaadinBuildFrontendOutputDirectory) { copySpec ->
104-
val archivePath =
105-
task.vaadinBuildFrontendResourcesArchivePath()
106-
if (archivePath != null) {
107-
copySpec.into(archivePath)
108-
}
109-
}
110-
}
111-
}
82+
if (vaadinBuildFrontendOutputDirectory != null) {
83+
// Expose the production bundle (a task-owned tree laid out
84+
// as META-INF/VAADIN/...) as an extra output directory of
85+
// the source set. Through standard Gradle wiring this puts
86+
// it on the runtime classpath - so in-place runners (e.g.
87+
// gretty serving during a production build) find the bundle
88+
// and the production flow-build-info.json - and packages it
89+
// into every application archive (WEB-INF/classes for War,
90+
// the archive root for a plain or Spring Boot executable
91+
// jar), while archives that don't consume the source set
92+
// output (sources/javadoc jars) stay frontend-free. builtBy
93+
// wires every consumer to vaadinBuildFrontend, so no
94+
// explicit task dependency is needed. The bundle stays out
95+
// of build/resources/main, so vaadinBuildFrontend keeps its
96+
// own declared output and remains build-cache correct.
97+
project.getSourceSet(config.sourceSetName.get()).output.dir(
98+
mapOf("builtBy" to buildFrontendTask),
99+
vaadinBuildFrontendOutputDirectory
100+
)
112101
}
113102
} else if (config.alwaysExecutePrepareFrontend.get()) {
114103
// In development mode, vaadinPrepareFrontend is not
@@ -175,19 +164,14 @@ public class FlowPlugin : Plugin<Project> {
175164
// Ensure close() fires after vaadinBuildFrontend and
176165
// all Jar/War packaging tasks have completed.
177166
buildFrontendTask.usesService(tokenService)
178-
val includedArchiveTasks = config.includeArchiveTasks.get().toSet()
179-
val excludedArchiveTasks = config.excludeArchiveTasks.get().toSet()
180167
project.tasks.withType(Jar::class.java) { task: Jar ->
181-
if (task.isVaadinApplicationArchiveTask(
182-
includedArchiveTasks, excludedArchiveTasks)) {
183-
task.usesService(tokenService)
184-
// Restore the production token before packaging in
185-
// case it was deleted by a previous build's cleanup.
186-
// Capture the service provider rather than the Project so
187-
// the action stays compatible with the configuration cache.
188-
task.doFirst {
189-
tokenService.get().ensureToken()
190-
}
168+
task.usesService(tokenService)
169+
// Restore the production token before packaging in
170+
// case it was deleted by a previous build's cleanup.
171+
// Capture the service provider rather than the Project so
172+
// the action stays compatible with the configuration cache.
173+
task.doFirst {
174+
tokenService.get().ensureToken()
191175
}
192176
}
193177
}
@@ -205,38 +189,4 @@ public class FlowPlugin : Plugin<Project> {
205189
)
206190
}
207191
}
208-
209-
private fun Jar.vaadinBuildFrontendResourcesArchivePath(): String? {
210-
return when {
211-
this is War -> "WEB-INF/classes"
212-
isSpringBootJar() -> "BOOT-INF/classes"
213-
else -> null
214-
}
215-
}
216-
217-
// Whether the production frontend bundle should be packaged into this
218-
// archive. By default every Jar/War/BootJar qualifies, except source,
219-
// javadoc and test-fixture distributions (identified by their archive
220-
// classifier). The default can be overridden per task via the
221-
// `includeArchiveTasks` / `excludeArchiveTasks` plugin settings, with
222-
// `excludeArchiveTasks` taking precedence.
223-
private fun Jar.isVaadinApplicationArchiveTask(
224-
includedTaskNames: Set<String>,
225-
excludedTaskNames: Set<String>
226-
): Boolean {
227-
if (name in excludedTaskNames) {
228-
return false
229-
}
230-
if (name in includedTaskNames) {
231-
return true
232-
}
233-
return archiveClassifier.orNull.orEmpty() !in
234-
NON_APPLICATION_ARCHIVE_CLASSIFIERS
235-
}
236-
237-
private fun Jar.isSpringBootJar(): Boolean =
238-
generateSequence(javaClass as Class<*>) { it.superclass }
239-
.any {
240-
it.name == "org.springframework.boot.gradle.tasks.bundling.BootJar"
241-
}
242192
}

flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/VaadinBuildFrontendTask.kt

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,17 @@ public abstract class VaadinBuildFrontendTask : DefaultTask() {
136136

137137
// Maven's task run in the LifecyclePhase.PROCESS_CLASSES phase
138138

139-
// We need access to the produced classes, to be able to analyze e.g.
140-
// @CssImport annotations used by the project.
141-
dependsOn("classes")
139+
// We need access to the produced classes and resources, to analyze
140+
// e.g. @CssImport annotations and to pick up frontend resources served
141+
// from the classpath (META-INF/resources/frontend). Ordering is
142+
// established granularly in configure(): after compilation via the
143+
// @Classpath `projectClassesDirs` input, and after resource processing
144+
// via an explicit dependency on the processResources task. We avoid
145+
// depending on the `classes` lifecycle task because, in production
146+
// mode, the bundle produced by this task is registered as an extra
147+
// `main` source set output directory (so it reaches the runtime
148+
// classpath and application archives), which makes `classes` depend on
149+
// this task - depending back on `classes` would form a cycle.
142150

143151
// Make sure to run this task before the `war`/`jar` tasks, so that
144152
// vite bundle will end up packaged in the war/jar archive. The inclusion
@@ -163,6 +171,15 @@ public abstract class VaadinBuildFrontendTask : DefaultTask() {
163171
val productionMode = config.productionMode
164172
onlyIf("Vaadin production mode is enabled") { productionMode.get() }
165173

174+
// Frontend scanning reads resources served from the classpath (e.g.
175+
// META-INF/resources/frontend), so this task must run after the source
176+
// set resources have been processed. Compilation ordering is covered
177+
// by the @Classpath projectClassesDirs input. Depending on the
178+
// processResources task directly (rather than the `classes` lifecycle
179+
// task) avoids the production-mode dependency cycle described in the
180+
// constructor.
181+
dependsOn(config.processResourcesTaskName)
182+
166183
// Track user-written frontend source files, excluding the
167184
// generated/ subdirectory (modified by this task) and index.html
168185
// (may be created by this task when missing; tracked as an output

0 commit comments

Comments
 (0)