Skip to content

Commit 55e4100

Browse files
authored
Handle pre-compressed assets better (#5370)
2 parents 4940e1c + d574851 commit 55e4100

File tree

4 files changed

+156
-137
lines changed

4 files changed

+156
-137
lines changed

crates/cli/src/server.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,9 @@ pub fn build_router(
248248
mas_config::HttpResource::Assets { path } => {
249249
let static_service = ServeDir::new(path)
250250
.append_index_html_on_directories(false)
251+
// The vite build pre-compresses assets with brotli and gzip
251252
.precompressed_br()
252-
.precompressed_gzip()
253-
.precompressed_deflate();
253+
.precompressed_gzip();
254254

255255
let add_cache_headers = axum::middleware::map_response(
256256
async |mut res: Response<ServeFileSystemResponseBody>| {

frontend/package-lock.json

Lines changed: 0 additions & 69 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,7 @@
7777
"tinyglobby": "^0.2.15",
7878
"typescript": "^5.9.3",
7979
"vite": "7.3.0",
80-
"vite-plugin-compression": "^0.5.1",
8180
"vite-plugin-graphql-codegen": "^3.7.0",
82-
"vite-plugin-manifest-sri": "^0.2.0",
8381
"vitest": "^4.0.15"
8482
},
8583
"msw": {

frontend/vite.config.ts

Lines changed: 154 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@
44
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
// Please see LICENSE files in the repository root for full details.
66

7-
import { readFile, writeFile } from "node:fs/promises";
7+
import { type FileHandle, open } from "node:fs/promises";
88
import { resolve } from "node:path";
9+
import { promisify } from "node:util";
10+
import zlib from "node:zlib";
911
import { tanstackRouter } from "@tanstack/router-plugin/vite";
1012
import react from "@vitejs/plugin-react";
1113
import browserslistToEsbuild from "browserslist-to-esbuild";
1214
import { globSync } from "tinyglobby";
13-
import type { Manifest, PluginOption } from "vite";
14-
import compression from "vite-plugin-compression";
15+
import type { Environment, Manifest, PluginOption } from "vite";
1516
import codegen from "vite-plugin-graphql-codegen";
16-
import manifestSRI from "vite-plugin-manifest-sri";
1717
import { defineConfig } from "vitest/config";
1818

1919
function i18nHotReload(): PluginOption {
@@ -31,6 +31,154 @@ function i18nHotReload(): PluginOption {
3131
};
3232
}
3333

34+
// Pre-compress the assets, so that the server can serve them directly
35+
function compression(): PluginOption {
36+
const gzip = promisify(zlib.gzip);
37+
const brotliCompress = promisify(zlib.brotliCompress);
38+
39+
return {
40+
name: "asset-compression",
41+
apply: "build",
42+
enforce: "post",
43+
44+
async generateBundle(_outputOptions, bundle) {
45+
const promises = Object.entries(bundle).flatMap(
46+
([fileName, assetOrChunk]) => {
47+
const source =
48+
assetOrChunk.type === "asset"
49+
? assetOrChunk.source
50+
: assetOrChunk.code;
51+
52+
// Don't compress empty files, only compress CSS, JS and JSON files
53+
if (
54+
!source ||
55+
!(
56+
fileName.endsWith(".js") ||
57+
fileName.endsWith(".css") ||
58+
fileName.endsWith(".json")
59+
)
60+
) {
61+
return [];
62+
}
63+
64+
const uncompressed = Buffer.from(source);
65+
66+
// We pre-compress assets with brotli as it offers the best
67+
// compression ratios compared to even zstd, and gzip as a fallback
68+
return [
69+
{ compress: gzip, ext: "gz" },
70+
{ compress: brotliCompress, ext: "br" },
71+
].map(async ({ compress, ext }) => {
72+
const compressed = await compress(uncompressed);
73+
74+
this.emitFile({
75+
type: "asset",
76+
fileName: `${fileName}.${ext}`,
77+
source: compressed,
78+
});
79+
});
80+
},
81+
);
82+
83+
await Promise.all(promises);
84+
},
85+
};
86+
}
87+
88+
declare module "vite" {
89+
interface ManifestChunk {
90+
integrity: string;
91+
}
92+
}
93+
94+
// Custom plugin to make sure that each asset has an entry in the manifest
95+
// This is needed so that the preloading & asset integrity generation works
96+
// It also calculates integrity hashes for the assets
97+
function augmentManifest(): PluginOption {
98+
// Store a per-environment state, in case the build is run multiple times, like in watch mode
99+
const state = new Map<Environment, Record<string, Promise<string>>>();
100+
return {
101+
name: "augment-manifest",
102+
apply: "build",
103+
enforce: "post",
104+
105+
perEnvironmentStartEndDuringDev: true,
106+
buildStart() {
107+
state.set(this.environment, {});
108+
},
109+
110+
generateBundle(_outputOptions, bundle) {
111+
const envState = state.get(this.environment);
112+
if (!envState) throw new Error("No state for environment");
113+
114+
for (const [fileName, assetOrChunk] of Object.entries(bundle)) {
115+
// Start calculating hash of the asset. We can let that run in the
116+
// background
117+
const source =
118+
assetOrChunk.type === "asset"
119+
? assetOrChunk.source
120+
: assetOrChunk.code;
121+
122+
envState[fileName] = (async (): Promise<string> => {
123+
const digest = await crypto.subtle.digest(
124+
"SHA-384",
125+
Buffer.from(source),
126+
);
127+
return `sha384-${Buffer.from(digest).toString("base64")}`;
128+
})();
129+
}
130+
},
131+
132+
async writeBundle({ dir }): Promise<void> {
133+
const envState = state.get(this.environment);
134+
if (!envState) throw new Error("No state for environment");
135+
state.delete(this.environment);
136+
137+
const manifestPath = resolve(dir, "manifest.json");
138+
139+
let manifestHandle: FileHandle;
140+
try {
141+
manifestHandle = await open(manifestPath, "r+");
142+
} catch (error) {
143+
// Manifest does not exist, nothing to do but still warn about
144+
this.warn(`Failed to open manifest at ${manifestPath}: ${error}`);
145+
return;
146+
}
147+
const rawManifest = await manifestHandle.readFile("utf-8");
148+
const manifest = JSON.parse(rawManifest) as Manifest;
149+
150+
const existing: Set<string> = new Set();
151+
const needs: Set<string> = new Set();
152+
153+
for (const chunk of Object.values(manifest)) {
154+
existing.add(chunk.file);
155+
chunk.integrity = await envState[chunk.file];
156+
for (const css of chunk.css ?? []) needs.add(css);
157+
for (const sub of chunk.assets ?? []) needs.add(sub);
158+
}
159+
160+
const missing = Array.from(needs).filter((a) => !existing.has(a));
161+
162+
for (const asset of missing) {
163+
manifest[asset] = {
164+
file: asset,
165+
integrity: await envState[asset],
166+
};
167+
}
168+
169+
// Overwrite the manifest with the augmented entries
170+
// XXX: you'd think that doing `manifestHandle.writeFile` would work, as
171+
// the docs says that it 'overwrites the file if it exists'. Turns out, it
172+
// reuses the previous position from `readFile`, so that would append on
173+
// the existing, so we have to use `write` with an explicit position.
174+
// Truncating the file just in case the output is smaller than before.
175+
await manifestHandle.truncate(0);
176+
await manifestHandle.write(JSON.stringify(manifest, null, 2), 0, "utf-8");
177+
await manifestHandle.close();
178+
},
179+
};
180+
}
181+
34182
export default defineConfig((env) => ({
35183
base: "./",
36184

@@ -69,67 +217,9 @@ export default defineConfig((env) => ({
69217

70218
react(),
71219

72-
// Custom plugin to make sure that each asset has an entry in the manifest
73-
// This is needed so that the preloading & asset integrity generation works
74-
{
75-
name: "manifest-missing-assets",
76-
77-
apply: "build",
78-
enforce: "post",
79-
writeBundle: {
80-
// This needs to be executed sequentially before the manifestSRI plugin
81-
sequential: true,
82-
order: "pre",
83-
async handler({ dir }): Promise<void> {
84-
const manifestPath = resolve(dir, "manifest.json");
85-
86-
const manifest: Manifest | undefined = await readFile(
87-
manifestPath,
88-
"utf-8",
89-
).then(JSON.parse, () => undefined);
90-
91-
if (manifest) {
92-
const existing: Set<string> = new Set();
93-
const needs: Set<string> = new Set();
94-
95-
for (const chunk of Object.values(manifest)) {
96-
existing.add(chunk.file);
97-
for (const css of chunk.css ?? []) needs.add(css);
98-
for (const sub of chunk.assets ?? []) needs.add(sub);
99-
}
100-
101-
const missing = Array.from(needs).filter((a) => !existing.has(a));
102-
103-
if (missing.length > 0) {
104-
for (const asset of missing) {
105-
manifest[asset] = {
106-
file: asset,
107-
integrity: "",
108-
};
109-
}
110-
111-
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
112-
}
113-
}
114-
},
115-
},
116-
},
117-
118-
manifestSRI(),
220+
augmentManifest(),
119221

120-
// Pre-compress the assets, so that the server can serve them directly
121-
compression({
122-
algorithm: "gzip",
123-
ext: ".gz",
124-
}),
125-
compression({
126-
algorithm: "brotliCompress",
127-
ext: ".br",
128-
}),
129-
compression({
130-
algorithm: "deflate",
131-
ext: ".zz",
132-
}),
222+
compression(),
133223

134224
i18nHotReload(),
135225
],

0 commit comments

Comments
 (0)