Skip to content

Commit eeefe61

Browse files
authored
feat: compress static resources (#25034)
Add compression of static resources in META-INF/resources to Brotli and Gzip for production build. fixes #18033
1 parent 71004a6 commit eeefe61

11 files changed

Lines changed: 738 additions & 2 deletions

File tree

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,26 @@ public String getNodeExecutable() {
360360
return ensureNodeResolved().nodeExecutable();
361361
}
362362

363+
/**
364+
* Returns the path to a Node.js executable that can be used without
365+
* downloading or installing one, either a Node.js that has already been
366+
* resolved during this build or a globally available Node.js found on the
367+
* system {@code PATH}. Unlike {@link #getNodeExecutable()}, this never
368+
* triggers a Node.js installation.
369+
*
370+
* @return the path to an already available node executable, or {@code null}
371+
* if none is available without installation
372+
*/
373+
public String getExistingNodeExecutable() {
374+
NodeResolver.ActiveNodeInstallation active = activeNodeInstallation;
375+
if (active != null) {
376+
return active.nodeExecutable();
377+
}
378+
String nodeCommand = FrontendUtils.isWindows() ? "node.exe" : "node";
379+
return frontendToolsLocator.tryLocateTool(nodeCommand)
380+
.map(File::getAbsolutePath).orElse(null);
381+
}
382+
363383
/**
364384
* Ensures that node has been resolved and cached. Uses double-checked
365385
* locking to ensure thread-safe lazy initialization.

flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/NodeTasks.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ public class NodeTasks implements FallibleCommand {
9393
TaskGenerateBootstrap.class,
9494
TaskRunDevBundleBuild.class,
9595
TaskProcessStylesheetCss.class,
96+
TaskCompressStaticResources.class,
9697
TaskCleanFrontendFiles.class,
9798
TaskWriteGeneratedFilesList.class,
9899
TaskRemoveOldFrontendGeneratedFiles.class
@@ -152,6 +153,9 @@ public NodeTasks(Options options) {
152153
}
153154
// Process @StyleSheet CSS files (minify and inline @imports)
154155
commands.add(new TaskProcessStylesheetCss(options));
156+
// Pre-compress static resources (the processed CSS and other
157+
// compressible assets) into brotli/gzip siblings
158+
commands.add(new TaskCompressStaticResources(options));
155159
} else if (options.isBundleBuild()) {
156160
// The dev bundle check needs the frontendDependencies to be
157161
// able to
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.server.frontend;
17+
18+
import java.io.BufferedReader;
19+
import java.io.File;
20+
import java.io.IOException;
21+
import java.io.InputStream;
22+
import java.io.InputStreamReader;
23+
import java.io.OutputStream;
24+
import java.nio.charset.StandardCharsets;
25+
import java.nio.file.FileVisitResult;
26+
import java.nio.file.Files;
27+
import java.nio.file.Path;
28+
import java.nio.file.SimpleFileVisitor;
29+
import java.nio.file.StandardCopyOption;
30+
import java.nio.file.attribute.BasicFileAttributes;
31+
import java.util.List;
32+
import java.util.Locale;
33+
import java.util.zip.Deflater;
34+
import java.util.zip.GZIPOutputStream;
35+
36+
import org.slf4j.Logger;
37+
import org.slf4j.LoggerFactory;
38+
39+
import com.vaadin.flow.internal.FrontendUtils;
40+
41+
/**
42+
* Pre-compresses static resources served straight from META-INF/resources (e.g.
43+
* CSS referenced with {@code @StyleSheet}, JavaScript, SVG) into brotli
44+
* ({@code .br}) and gzip ({@code .gz}) siblings, mirroring the compression Vite
45+
* applies to bundled assets. These resources never enter the Vite bundle, so
46+
* without this task they would always be served uncompressed.
47+
* <p>
48+
* Runs on every production build, independent of whether the frontend bundle is
49+
* rebuilt or reused, and after {@link TaskProcessStylesheetCss} so the already
50+
* minified and inlined CSS gets compressed.
51+
* <p>
52+
* The JDK cannot produce brotli, so brotli is delegated to a small Node script
53+
* relying solely on Node's built-in {@code zlib}. Node is only used when it is
54+
* already available, so it is never downloaded merely to compress resources;
55+
* when Node is not available the task still produces gzip variants using the
56+
* JDK. Both variants are pre-compressed so the server can serve whichever the
57+
* client accepts.
58+
* <p>
59+
* For internal use only. May be renamed or removed in a future release.
60+
*/
61+
public class TaskCompressStaticResources implements FallibleCommand {
62+
63+
private static final String COMPRESSION_SCRIPT = "compress-static-resources.mjs";
64+
65+
/**
66+
* File types that benefit from text compression. Kept in sync with the
67+
* {@value #COMPRESSION_SCRIPT} Node script used for brotli.
68+
*/
69+
private static final List<String> COMPRESSIBLE_EXTENSIONS = List.of(".css",
70+
".js", ".mjs", ".cjs", ".json", ".map", ".svg", ".html", ".htm",
71+
".xml", ".txt");
72+
73+
/**
74+
* Below this size the compressed variant tends to be as large as, or larger
75+
* than, the original, so serving precompressed brings no benefit. Kept in
76+
* sync with the {@value #COMPRESSION_SCRIPT} Node script.
77+
*/
78+
private static final long MIN_SIZE_BYTES = 1024;
79+
80+
private final Options options;
81+
82+
/**
83+
* Creates a new task for compressing static resources.
84+
*
85+
* @param options
86+
* the task options
87+
*/
88+
TaskCompressStaticResources(Options options) {
89+
this.options = options;
90+
}
91+
92+
@Override
93+
public void execute() throws ExecutionFailedException {
94+
if (!options.isProductionMode()) {
95+
getLogger().debug(
96+
"Skipping static resource compression in non-production mode");
97+
return;
98+
}
99+
100+
File resourcesDir = options.getMetaInfResourcesDirectory();
101+
if (resourcesDir == null || !resourcesDir.exists()) {
102+
getLogger().debug(
103+
"META-INF/resources directory not found, skipping static resource compression");
104+
return;
105+
}
106+
107+
// Compression is a best-effort optimization: the uncompressed resource
108+
// is always available, so a failure here is logged as a warning and
109+
// never fails the build.
110+
111+
// Brotli requires Node, which the JDK cannot provide. Use Node only if
112+
// it is already available so it is never downloaded solely for
113+
// compression; otherwise fall back to gzip, which the JDK provides.
114+
String nodeExecutable = findExistingNode();
115+
if (nodeExecutable != null) {
116+
compressWithNode(nodeExecutable, resourcesDir);
117+
} else {
118+
getLogger().debug(
119+
"Node is not available without installation, compressing static resources with gzip only");
120+
gzipStaticResources(resourcesDir);
121+
}
122+
}
123+
124+
private String findExistingNode() {
125+
try {
126+
return FrontendTools.fromOptions(options)
127+
.getExistingNodeExecutable();
128+
} catch (RuntimeException e) {
129+
getLogger().debug("Could not determine whether Node is available",
130+
e);
131+
return null;
132+
}
133+
}
134+
135+
/**
136+
* Produces brotli and gzip siblings by running the Node compression script.
137+
*/
138+
private void compressWithNode(String nodeExecutable, File resourcesDir) {
139+
File script = extractCompressionScript();
140+
if (script == null) {
141+
return;
142+
}
143+
try {
144+
List<String> command = List.of(nodeExecutable,
145+
script.getAbsolutePath(), resourcesDir.getAbsolutePath());
146+
147+
ProcessBuilder builder = FrontendUtils
148+
.createProcessBuilder(command);
149+
builder.redirectErrorStream(true);
150+
Process process = builder.start();
151+
152+
StringBuilder output = new StringBuilder();
153+
try (BufferedReader reader = new BufferedReader(
154+
new InputStreamReader(process.getInputStream(),
155+
StandardCharsets.UTF_8))) {
156+
String line;
157+
while ((line = reader.readLine()) != null) {
158+
output.append(line).append(System.lineSeparator());
159+
}
160+
}
161+
162+
int exitCode = process.waitFor();
163+
if (exitCode != 0) {
164+
getLogger().warn(
165+
"Compressing static resources exited with code {}, "
166+
+ "resources will be served uncompressed:\n{}",
167+
exitCode, output);
168+
}
169+
} catch (IOException e) {
170+
getLogger().warn(
171+
"Failed to compress static resources, they will be served "
172+
+ "uncompressed",
173+
e);
174+
} catch (InterruptedException e) {
175+
Thread.currentThread().interrupt();
176+
getLogger().warn(
177+
"Interrupted while compressing static resources, they will "
178+
+ "be served uncompressed",
179+
e);
180+
} finally {
181+
script.delete();
182+
}
183+
}
184+
185+
/**
186+
* Produces gzip siblings using only the JDK, for when Node is not available
187+
* without an installation.
188+
*/
189+
void gzipStaticResources(File resourcesDir) {
190+
try {
191+
Files.walkFileTree(resourcesDir.toPath(),
192+
new SimpleFileVisitor<Path>() {
193+
@Override
194+
public FileVisitResult visitFile(Path file,
195+
BasicFileAttributes attrs) throws IOException {
196+
gzipIfNeeded(file, attrs.size());
197+
return FileVisitResult.CONTINUE;
198+
}
199+
});
200+
} catch (IOException e) {
201+
getLogger().warn(
202+
"Failed to gzip static resources, they will be served "
203+
+ "uncompressed",
204+
e);
205+
}
206+
}
207+
208+
private void gzipIfNeeded(Path file, long size) throws IOException {
209+
String name = file.getFileName().toString().toLowerCase(Locale.ENGLISH);
210+
if (!isCompressible(name)) {
211+
return;
212+
}
213+
214+
// Always delete br file if exists as we only remake the gzip
215+
Files.deleteIfExists(file.resolveSibling(file.getFileName() + ".br"));
216+
Path gz = file.resolveSibling(file.getFileName() + ".gz");
217+
if (size < MIN_SIZE_BYTES) {
218+
// Remove any variants left by an earlier build where this file was
219+
// still above the threshold, so no stale compressed variant is
220+
// served now that it is served uncompressed.
221+
Files.deleteIfExists(gz);
222+
return;
223+
}
224+
try (InputStream in = Files.newInputStream(file);
225+
OutputStream out = new GZIPOutputStream(
226+
Files.newOutputStream(gz)) {
227+
{
228+
def.setLevel(Deflater.BEST_COMPRESSION);
229+
}
230+
}) {
231+
in.transferTo(out);
232+
}
233+
}
234+
235+
private static boolean isCompressible(String fileName) {
236+
return COMPRESSIBLE_EXTENSIONS.stream().anyMatch(fileName::endsWith);
237+
}
238+
239+
private File extractCompressionScript() {
240+
try (InputStream in = getClass()
241+
.getResourceAsStream(COMPRESSION_SCRIPT)) {
242+
if (in == null) {
243+
getLogger().warn(
244+
"Could not locate the static resource compression "
245+
+ "script on the classpath, static resources "
246+
+ "will be served uncompressed");
247+
return null;
248+
}
249+
File script = File
250+
.createTempFile("vaadin-compress-static-resources", ".mjs");
251+
script.deleteOnExit();
252+
Files.copy(in, script.toPath(),
253+
StandardCopyOption.REPLACE_EXISTING);
254+
return script;
255+
} catch (IOException e) {
256+
getLogger().warn(
257+
"Failed to prepare the static resource compression script, "
258+
+ "static resources will be served uncompressed",
259+
e);
260+
return null;
261+
}
262+
}
263+
264+
private static Logger getLogger() {
265+
return LoggerFactory.getLogger(TaskCompressStaticResources.class);
266+
}
267+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Pre-compresses static resources served straight from META-INF/resources
2+
// (e.g. @StyleSheet CSS) into brotli (.br) and gzip (.gz) siblings, mirroring
3+
// the compression Vite applies to bundled assets. Invoked with the resource
4+
// root directory as the single argument. Uses only Node built-ins so the Flow
5+
// build needs no additional dependency.
6+
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
7+
import { resolve } from 'node:path';
8+
import { brotliCompressSync, constants, gzipSync } from 'node:zlib';
9+
10+
// File types that benefit from text compression.
11+
const COMPRESSIBLE_EXTENSIONS = [
12+
'.css',
13+
'.js',
14+
'.mjs',
15+
'.cjs',
16+
'.json',
17+
'.map',
18+
'.svg',
19+
'.html',
20+
'.htm',
21+
'.xml',
22+
'.txt'
23+
];
24+
25+
// Below this size the compressed variant tends to be as large as, or larger
26+
// than, the original, so serving precompressed brings no benefit.
27+
const MIN_SIZE_BYTES = 1024;
28+
29+
function isCompressible(fileName) {
30+
const lower = fileName.toLowerCase();
31+
return COMPRESSIBLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
32+
}
33+
34+
function collectFiles(directory, collected) {
35+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
36+
const fullPath = resolve(directory, entry.name);
37+
if (entry.isDirectory()) {
38+
collectFiles(fullPath, collected);
39+
} else if (entry.isFile() && isCompressible(entry.name)) {
40+
collected.push(fullPath);
41+
}
42+
}
43+
}
44+
45+
const root = process.argv[2];
46+
if (!root || !existsSync(root)) {
47+
process.exit(0);
48+
}
49+
50+
const files = [];
51+
collectFiles(resolve(root), files);
52+
53+
for (const file of files) {
54+
const size = statSync(file).size;
55+
if (size < MIN_SIZE_BYTES) {
56+
// An earlier build may have produced .br/.gz siblings when this file was
57+
// still above the threshold. Remove them so a stale compressed variant is
58+
// not served now that the file is served uncompressed.
59+
rmSync(`${file}.br`, { force: true });
60+
rmSync(`${file}.gz`, { force: true });
61+
continue;
62+
}
63+
const contents = readFileSync(file);
64+
writeFileSync(
65+
`${file}.br`,
66+
brotliCompressSync(contents, {
67+
params: {
68+
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
69+
[constants.BROTLI_PARAM_SIZE_HINT]: size
70+
}
71+
})
72+
);
73+
writeFileSync(`${file}.gz`, gzipSync(contents, { level: 9 }));
74+
}

0 commit comments

Comments
 (0)