Skip to content

Commit c8c808b

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 c8c808b

File tree

3 files changed

+201
-27
lines changed

3 files changed

+201
-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: 107 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,101 @@ 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("Using existing Node {} instead of installing {}",
263+
fallbackVersion, nodeVersion);
264+
activeNodeVersion = fallbackVersion;
265+
return true;
266+
}
267+
256268
return false;
257269
}
258270

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

@@ -285,6 +371,7 @@ private void installNode(InstallData data) throws InstallationException {
285371
}
286372

287373
getLogger().info("Local node installation successful.");
374+
activeNodeVersion = nodeVersion;
288375
}
289376

290377
private void installNodeUnix(InstallData data)
@@ -424,7 +511,18 @@ public String getInstallDirectory() {
424511
}
425512

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

430528
private File getNodeInstallDirectory() {
@@ -627,17 +725,6 @@ private void verifyArchive(File archive)
627725
}
628726
}
629727

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-
641728
private static FrontendVersion getVersion(String tool,
642729
List<String> versionCommand) throws InstallationException {
643730
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)