From a6acd57044ac31ef45b984d04ffd02a6ce84b5ed Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:57:18 +0000 Subject: [PATCH] fix: recognise the nested jars of Spring Boot when looking up the token file The lookup for flow-build-info.json decides that the application is packaged into a jar by counting the archives the vite.generated.ts of flow-server is inside of, and then prefers the file in the outermost archive, which is the one of the application itself. Spring Boot 3.2 and newer separate the jar of the application from an archive nested in it with '/!' instead of '!/', so nothing was counted for it and the application never looked packaged. The file of a dependency was then used whenever the class loader happened to return it first. Both separators are now counted, so the file of the application wins again. --- ...efaultApplicationConfigurationFactory.java | 45 ++++++++++++------ ...ltApplicationConfigurationFactoryTest.java | 47 +++++++++++++++++-- 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/flow-server/src/main/java/com/vaadin/flow/server/startup/DefaultApplicationConfigurationFactory.java b/flow-server/src/main/java/com/vaadin/flow/server/startup/DefaultApplicationConfigurationFactory.java index a95a830b513..7e1e90a48cf 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/startup/DefaultApplicationConfigurationFactory.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/startup/DefaultApplicationConfigurationFactory.java @@ -209,18 +209,21 @@ private String getPossibleJarResource(VaadinContext context, URL viteGenerated = resourceProvider .getApplicationResource(FrontendUtils.VITE_GENERATED_CONFIG); - // If jar!/ exists 2 times for vite.generated.ts then we are - // running from a jar + // If vite.generated.ts is inside 2 archives then we are running from + // a jar, as the jar of flow-server is inside the jar of the + // application boolean runningFromJar = viteGenerated != null - && countInstances(viteGenerated.getPath(), "jar!/") >= 2; + && countArchiveLevels(viteGenerated.getPath()) >= 2; - // As we now know that we are running from a jar we can accept a - // build info with a single jar in the path, so look at those first - List candidates = runningFromJar ? resources.stream() - .sorted(Comparator.comparingInt( - url -> countInstances(url.getPath(), "jar!/") == 1 ? 0 - : 1)) - .toList() : resources; + // As we now know that we are running from a jar, the file of the + // application is the one in the outermost archive, so look at the + // least nested ones first + List candidates = runningFromJar + ? resources.stream() + .sorted(Comparator.comparingInt( + url -> countArchiveLevels(url.getPath()))) + .toList() + : resources; for (URL candidate : candidates) { String content = FrontendUtils @@ -272,13 +275,27 @@ private boolean isProductionModeTokenFile(String content) { } } + /** + * Counts inside how many archives the resource at the given path is. + *

+ * Both the {@code app.jar!/} separator used for a jar opened from the file + * system and the {@code app.jar/!} separator that Spring Boot 3.2 and newer + * use for an archive nested in the jar of the application are counted, as a + * path may contain one of each: + * {@code nested:/app.jar/!BOOT-INF/lib/flow-server.jar!/vite.generated.ts}. + * + * @param path + * the path of the resource, not {@code null} + * @return the number of archives the resource is inside of, {@code 0} if it + * is not inside one + */ + private int countArchiveLevels(String path) { + return countInstances(path, "jar!/") + countInstances(path, "jar/!"); + } + /** * Counts how many times {@code value} occurs as a non-overlapping substring * within {@code input}. - *

- * Used to determine how many nested {@code jar!/} segments appear in a - * resource path, which indicates whether the resource is packaged inside - * one or several jars. * * @param input * the string to search within, not {@code null} diff --git a/flow-server/src/test/java/com/vaadin/flow/server/startup/DefaultApplicationConfigurationFactoryTest.java b/flow-server/src/test/java/com/vaadin/flow/server/startup/DefaultApplicationConfigurationFactoryTest.java index c02b788b5c1..8fdb038180d 100644 --- a/flow-server/src/test/java/com/vaadin/flow/server/startup/DefaultApplicationConfigurationFactoryTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/server/startup/DefaultApplicationConfigurationFactoryTest.java @@ -229,6 +229,40 @@ void create_productionModeTokenFileInsideJar_tokenFileIsUsed() .getBooleanProperty(Constants.EXTERNAL_STATS_FILE, false)); } + @Test + void create_nestedJarsOfSpringBoot_tokenFileOfTheApplicationIsUsed() + throws IOException { + VaadinContext context = Mockito.mock(VaadinContext.class); + VaadinConfig config = Mockito.mock(VaadinConfig.class); + ResourceProvider resourceProvider = mockResourceProvider(config, + context); + + // Spring Boot 3.2 and newer separate the jar of the application from + // the archive nested in it with '/!' instead of '!/' + Mockito.when(resourceProvider + .getApplicationResource(FrontendUtils.VITE_GENERATED_CONFIG)) + .thenReturn(new URL("file", "", -1, + "nested:/opt/app.jar/!BOOT-INF/lib/flow-server.jar!/" + + FrontendUtils.VITE_GENERATED_CONFIG)); + + URL dependencyToken = mockTokenFileUrl( + "nested:/opt/app.jar/!BOOT-INF/lib/addon.jar!/", + "{ \"productionMode\": true, \"externalStatsUrl\": \"http://addon/stats.json\" }"); + URL applicationToken = mockTokenFileUrl("file:/opt/app.jar!/", + "{ \"productionMode\": true, \"externalStatsUrl\": \"http://application/stats.json\" }"); + Mockito.when(resourceProvider + .getApplicationResources(VAADIN_SERVLET_RESOURCES + TOKEN_FILE)) + .thenReturn(List.of(dependencyToken, applicationToken)); + + DefaultApplicationConfigurationFactory factory = new DefaultApplicationConfigurationFactory(); + ApplicationConfiguration configuration = factory.create(context); + + assertEquals("http://application/stats.json", + configuration.getStringProperty(Constants.EXTERNAL_STATS_URL, + null), + "The token file of the application should be used instead of the one of a dependency"); + } + @Test void getMode_returnsLivereload_tailwindCssIsEnabled() throws IOException { VaadinContext context = Mockito.mock(VaadinContext.class); @@ -298,6 +332,14 @@ private void mockJarTokenFile(ResourceProvider resourceProvider, private void mockClassPathTokenFile(ResourceProvider resourceProvider, String pathPrefix, String content) throws IOException, MalformedURLException { + Mockito.when(resourceProvider + .getApplicationResources(VAADIN_SERVLET_RESOURCES + TOKEN_FILE)) + .thenReturn(Collections + .singletonList(mockTokenFileUrl(pathPrefix, content))); + } + + private URL mockTokenFileUrl(String pathPrefix, String content) + throws IOException, MalformedURLException { String path = VAADIN_SERVLET_RESOURCES + TOKEN_FILE; File tmpFile = java.nio.file.Files @@ -311,10 +353,7 @@ protected URLConnection openConnection(URL u) throws IOException { return tmpFile.toURI().toURL().openConnection(); } }; - URL url = new URL("file", "", -1, pathPrefix + path, handler); - - Mockito.when(resourceProvider.getApplicationResources(path)) - .thenReturn(Collections.singletonList(url)); + return new URL("file", "", -1, pathPrefix + path, handler); } private ResourceProvider mockResourceProvider(VaadinConfig config,