Skip to content

Commit ee0f285

Browse files
committed
feat: Install Node.js in version-specific directories with fallback support
- Install Node.js to version-specific directories (e.g., node-v24.10.0/) instead of a single node/ directory to support multiple versions - Add fallback logic to use existing compatible Node.js installation when the requested version is not available - Scan install directory for node-v* folders, filter by minimum supported version (24.0), and select the newest compatible version - Make FrontendTools.SUPPORTED_NODE_VERSION public for reuse - Add FileIOUtils.copyDirectory() and delete() methods
1 parent 8a177ec commit ee0f285

File tree

3 files changed

+202
-27
lines changed

3 files changed

+202
-27
lines changed

flow-server/src/main/java/com/vaadin/flow/server/frontend/FrontendTools.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ public class FrontendTools {
119119
private static final int SUPPORTED_NPM_MAJOR_VERSION = 11;
120120
private static final int SUPPORTED_NPM_MINOR_VERSION = 3;
121121

122-
static final FrontendVersion SUPPORTED_NODE_VERSION = new FrontendVersion(
122+
public static final FrontendVersion SUPPORTED_NODE_VERSION = new FrontendVersion(
123123
SUPPORTED_NODE_MAJOR_VERSION, SUPPORTED_NODE_MINOR_VERSION);
124124

125125
private static final FrontendVersion SUPPORTED_NPM_VERSION = new FrontendVersion(

flow-server/src/main/java/com/vaadin/flow/server/frontend/installer/NodeInstaller.java

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import com.vaadin.flow.internal.MessageDigestUtil;
3737
import com.vaadin.flow.internal.Pair;
3838
import com.vaadin.flow.server.frontend.FileIOUtils;
39+
import com.vaadin.flow.server.frontend.FrontendTools;
3940
import com.vaadin.flow.server.frontend.FrontendUtils;
4041
import com.vaadin.flow.server.frontend.FrontendVersion;
4142
import com.vaadin.frontendtools.installer.ArchiveExtractionException;
@@ -53,14 +54,10 @@
5354
*/
5455
public class NodeInstaller {
5556

56-
public static final String INSTALL_PATH = "/node";
57+
public static final String INSTALL_PATH_PREFIX = "/node";
5758

5859
public static final String SHA_SUMS_FILE = "SHASUMS256.txt";
5960

60-
private static final String NODE_WINDOWS = INSTALL_PATH.replaceAll("/",
61-
"\\\\") + "\\node.exe";
62-
private static final String NODE_DEFAULT = INSTALL_PATH + "/node";
63-
6461
public static final String PROVIDED_VERSION = "provided";
6562

6663
private static final int MAX_DOWNLOAD_ATTEMPS = 5;
@@ -74,6 +71,11 @@ public class NodeInstaller {
7471

7572
private String npmVersion = PROVIDED_VERSION;
7673
private String nodeVersion;
74+
/**
75+
* The actual node version being used. May differ from nodeVersion if a
76+
* compatible fallback version was found.
77+
*/
78+
private String activeNodeVersion;
7779
private URI nodeDownloadRoot;
7880
private String userName;
7981
private String password;
@@ -234,9 +236,9 @@ public void install() throws InstallationException {
234236
}
235237

236238
private boolean nodeIsAlreadyInstalled() throws InstallationException {
237-
File nodeFile = getNodeExecutable();
239+
// First, check if the exact requested version is installed
240+
File nodeFile = getNodeExecutableForVersion(nodeVersion);
238241
if (nodeFile.exists()) {
239-
240242
List<String> nodeVersionCommand = new ArrayList<>();
241243
nodeVersionCommand.add(nodeFile.toString());
242244
nodeVersionCommand.add("--version");
@@ -245,17 +247,102 @@ private boolean nodeIsAlreadyInstalled() throws InstallationException {
245247

246248
if (version.equals(nodeVersion)) {
247249
getLogger().info("Node {} is already installed.", version);
250+
activeNodeVersion = nodeVersion;
248251
return true;
249252
} else {
250253
getLogger().info(
251254
"Node {} was installed, but we need version {}",
252255
version, nodeVersion);
253-
return false;
254256
}
255257
}
258+
259+
// Check if any other compatible version is available
260+
String fallbackVersion = findCompatibleInstalledVersion();
261+
if (fallbackVersion != null) {
262+
getLogger().debug(
263+
"Using existing Node {} instead of installing {}",
264+
fallbackVersion, nodeVersion);
265+
activeNodeVersion = fallbackVersion;
266+
return true;
267+
}
268+
256269
return false;
257270
}
258271

272+
/**
273+
* Scans the install directory for installed Node.js versions and returns
274+
* the newest one that is supported.
275+
*
276+
* @return the version string (e.g., "v24.10.0") of the best available
277+
* version, or null if none found
278+
*/
279+
private String findCompatibleInstalledVersion() {
280+
if (!installDirectory.exists() || !installDirectory.isDirectory()) {
281+
return null;
282+
}
283+
284+
File[] nodeDirs = installDirectory.listFiles(file -> file.isDirectory()
285+
&& file.getName().startsWith("node-v"));
286+
287+
if (nodeDirs == null || nodeDirs.length == 0) {
288+
return null;
289+
}
290+
291+
FrontendVersion bestVersion = null;
292+
String bestVersionString = null;
293+
294+
for (File nodeDir : nodeDirs) {
295+
String dirName = nodeDir.getName();
296+
// Extract version from directory name (node-v24.10.0 -> v24.10.0)
297+
String versionString = dirName.substring("node-".length());
298+
299+
try {
300+
FrontendVersion version = new FrontendVersion(versionString);
301+
302+
// Skip versions older than minimum supported
303+
if (version.isOlderThan(FrontendTools.SUPPORTED_NODE_VERSION)) {
304+
getLogger().debug(
305+
"Skipping {} - older than minimum supported {}",
306+
versionString,
307+
FrontendTools.SUPPORTED_NODE_VERSION
308+
.getFullVersion());
309+
continue;
310+
}
311+
312+
// Verify the node executable actually exists and works
313+
File nodeExecutable = getNodeExecutableForVersion(versionString);
314+
if (!nodeExecutable.exists()) {
315+
getLogger().debug(
316+
"Skipping {} - executable not found at {}",
317+
versionString, nodeExecutable);
318+
continue;
319+
}
320+
321+
// Keep the newest version
322+
if (bestVersion == null || version.isNewerThan(bestVersion)) {
323+
bestVersion = version;
324+
bestVersionString = versionString;
325+
}
326+
} catch (NumberFormatException e) {
327+
getLogger().debug("Could not parse version from directory: {}",
328+
dirName);
329+
}
330+
}
331+
332+
return bestVersionString;
333+
}
334+
335+
/**
336+
* Gets the node executable path for a specific version.
337+
*/
338+
private File getNodeExecutableForVersion(String version) {
339+
String versionedPath = INSTALL_PATH_PREFIX + "-" + version;
340+
String nodeExecutable = platform.isWindows()
341+
? versionedPath.replaceAll("/", "\\\\") + "\\node.exe"
342+
: versionedPath + "/bin/node";
343+
return new File(installDirectory + nodeExecutable);
344+
}
345+
259346
private void installNode(InstallData data) throws InstallationException {
260347
try {
261348

@@ -285,6 +372,7 @@ private void installNode(InstallData data) throws InstallationException {
285372
}
286373

287374
getLogger().info("Local node installation successful.");
375+
activeNodeVersion = nodeVersion;
288376
}
289377

290378
private void installNodeUnix(InstallData data)
@@ -424,7 +512,18 @@ public String getInstallDirectory() {
424512
}
425513

426514
private File getInstallDirectoryFile() {
427-
return new File(installDirectory, INSTALL_PATH);
515+
return new File(installDirectory, getVersionedInstallPath());
516+
}
517+
518+
private String getVersionedInstallPath() {
519+
// Use activeNodeVersion if set (fallback version), otherwise use
520+
// requested nodeVersion
521+
String version = activeNodeVersion != null ? activeNodeVersion
522+
: nodeVersion;
523+
if (version == null || PROVIDED_VERSION.equals(version)) {
524+
return INSTALL_PATH_PREFIX;
525+
}
526+
return INSTALL_PATH_PREFIX + "-" + version;
428527
}
429528

430529
private File getNodeInstallDirectory() {
@@ -627,17 +726,6 @@ private void verifyArchive(File archive)
627726
}
628727
}
629728

630-
/**
631-
* Get node executable file.
632-
*
633-
* @return node executable
634-
*/
635-
private File getNodeExecutable() {
636-
String nodeExecutable = platform.isWindows() ? NODE_WINDOWS
637-
: NODE_DEFAULT;
638-
return new File(installDirectory + nodeExecutable);
639-
}
640-
641729
private static FrontendVersion getVersion(String tool,
642730
List<String> versionCommand) throws InstallationException {
643731
try {

flow-server/src/test/java/com/vaadin/flow/server/frontend/installer/NodeInstallerTest.java

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,10 @@ public void installNodeFromFileSystem_NodeIsInstalledToTargetDirectory()
105105
}
106106
}
107107

108-
// add a file to node/node_modules_npm that should be cleaned out
109-
File nodeDirectory = new File(targetDir, "node");
108+
// add a file to node-{version}/node_modules_npm that should be cleaned
109+
// out
110+
String versionedNodeDir = "node-" + FrontendTools.DEFAULT_NODE_VERSION;
111+
File nodeDirectory = new File(targetDir, versionedNodeDir);
110112
String nodeModulesPath = platform.isWindows() ? "node_modules"
111113
: "lib/node_modules";
112114
File nodeModulesDirectory = new File(nodeDirectory, nodeModulesPath);
@@ -133,11 +135,12 @@ public void installNodeFromFileSystem_NodeIsInstalledToTargetDirectory()
133135
throw new IllegalStateException("Failed to install Node", e);
134136
}
135137

136-
Assert.assertTrue("npm should have been copied to node_modules",
137-
new File(targetDir, "node/" + nodeExec).exists());
138+
Assert.assertTrue("node should have been installed",
139+
new File(targetDir, versionedNodeDir + "/" + nodeExec)
140+
.exists());
138141
String npmInstallPath = platform.isWindows()
139-
? "node/node_modules/npm/bin/npm"
140-
: "node/lib/node_modules/npm/bin/npm";
142+
? versionedNodeDir + "/node_modules/npm/bin/npm"
143+
: versionedNodeDir + "/lib/node_modules/npm/bin/npm";
141144
Assert.assertTrue("npm should have been copied to node_modules",
142145
new File(targetDir, npmInstallPath).exists());
143146
Assert.assertFalse("old npm files should have been removed",
@@ -146,4 +149,88 @@ public void installNodeFromFileSystem_NodeIsInstalledToTargetDirectory()
146149
"old style node_modules files should have been removed",
147150
oldGarbage.exists());
148151
}
152+
153+
@Test
154+
public void fallbackToExistingCompatibleVersion() throws IOException {
155+
Platform platform = Platform.guess();
156+
String nodeExec = platform.isWindows() ? "node.exe" : "node";
157+
158+
File targetDir = new File(baseDir + "/installation");
159+
160+
// Pre-install an older but compatible version (v24.8.0)
161+
String existingVersion = "v24.8.0";
162+
String existingVersionDir = "node-" + existingVersion;
163+
File existingNodeDir = new File(targetDir, existingVersionDir);
164+
File existingBinDir = platform.isWindows() ? existingNodeDir
165+
: new File(existingNodeDir, "bin");
166+
FileUtils.forceMkdir(existingBinDir);
167+
File existingNodeExec = new File(existingBinDir, nodeExec);
168+
Assert.assertTrue("Existing node executable should be created",
169+
existingNodeExec.createNewFile());
170+
171+
// Request a newer version that doesn't exist
172+
String requestedVersion = "v24.99.0";
173+
174+
NodeInstaller nodeInstaller = new NodeInstaller(targetDir,
175+
Collections.emptyList()).setNodeVersion(requestedVersion)
176+
.setNodeDownloadRoot(new File(baseDir).toPath().toUri());
177+
178+
// The install should succeed by falling back to v24.8.0
179+
try {
180+
nodeInstaller.install();
181+
} catch (InstallationException e) {
182+
// Expected - download will fail, but fallback should work
183+
Assert.fail(
184+
"Should have used fallback version instead of trying to download: "
185+
+ e.getMessage());
186+
}
187+
188+
// Verify that the install directory points to the fallback version
189+
Assert.assertEquals(
190+
"Install directory should point to fallback version",
191+
new File(targetDir, existingVersionDir).getPath(),
192+
nodeInstaller.getInstallDirectory());
193+
}
194+
195+
@Test
196+
public void fallbackSelectsNewestCompatibleVersion() throws IOException {
197+
Platform platform = Platform.guess();
198+
String nodeExec = platform.isWindows() ? "node.exe" : "node";
199+
200+
File targetDir = new File(baseDir + "/installation");
201+
202+
// Pre-install multiple versions
203+
for (String version : new String[] { "v24.5.0", "v24.8.0", "v24.6.0",
204+
"v23.0.0" }) {
205+
String versionDir = "node-" + version;
206+
File nodeDir = new File(targetDir, versionDir);
207+
File binDir = platform.isWindows() ? nodeDir
208+
: new File(nodeDir, "bin");
209+
FileUtils.forceMkdir(binDir);
210+
File nodeExecFile = new File(binDir, nodeExec);
211+
nodeExecFile.createNewFile();
212+
}
213+
214+
// Request a version that doesn't exist
215+
String requestedVersion = "v24.99.0";
216+
217+
NodeInstaller nodeInstaller = new NodeInstaller(targetDir,
218+
Collections.emptyList()).setNodeVersion(requestedVersion)
219+
.setNodeDownloadRoot(new File(baseDir).toPath().toUri());
220+
221+
try {
222+
nodeInstaller.install();
223+
} catch (InstallationException e) {
224+
Assert.fail(
225+
"Should have used fallback version instead of trying to download: "
226+
+ e.getMessage());
227+
}
228+
229+
// Verify that the newest compatible version (v24.8.0) was selected
230+
// Note: v23.0.0 should be skipped as it's below minimum supported
231+
// version
232+
Assert.assertEquals("Should select newest compatible version (v24.8.0)",
233+
new File(targetDir, "node-v24.8.0").getPath(),
234+
nodeInstaller.getInstallDirectory());
235+
}
149236
}

0 commit comments

Comments
 (0)