Files
letro-authentication-service/frontend/vite.config.ts
Quentin Gliech 4eb729498e Replace vite-plugin-sri with our own plugin
This avoids rewriting the manifest file multiple times, and it's also
slightly more efficient, as it doesn't re-read the assets from disk
2025-12-18 16:05:25 +01:00

248 lines
7.3 KiB
TypeScript

// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
import { type FileHandle, open } from "node:fs/promises";
import { resolve } from "node:path";
import { promisify } from "node:util";
import zlib from "node:zlib";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import browserslistToEsbuild from "browserslist-to-esbuild";
import { globSync } from "tinyglobby";
import type { Environment, Manifest, PluginOption } from "vite";
import codegen from "vite-plugin-graphql-codegen";
import { defineConfig } from "vitest/config";
function i18nHotReload(): PluginOption {
return {
name: "i18n-hot-reload",
handleHotUpdate({ file, server }): void {
if (file.includes("locales") && file.endsWith(".json")) {
console.log("Locale file updated");
server.hot.send({
type: "custom",
event: "locales-update",
});
}
},
};
}
// Pre-compress the assets, so that the server can serve them directly
function compression(): PluginOption {
const gzip = promisify(zlib.gzip);
const brotliCompress = promisify(zlib.brotliCompress);
return {
name: "asset-compression",
apply: "build",
enforce: "post",
async generateBundle(_outputOptions, bundle) {
const promises = Object.entries(bundle).flatMap(
([fileName, assetOrChunk]) => {
const source =
assetOrChunk.type === "asset"
? assetOrChunk.source
: assetOrChunk.code;
// Don't compress empty files, only compress CSS, JS and JSON files
if (
!source ||
!(
fileName.endsWith(".js") ||
fileName.endsWith(".css") ||
fileName.endsWith(".json")
)
) {
return [];
}
const uncompressed = Buffer.from(source);
// We pre-compress assets with brotli as it offers the best
// compression ratios compared to even zstd, and gzip as a fallback
return [
{ compress: gzip, ext: "gz" },
{ compress: brotliCompress, ext: "br" },
].map(async ({ compress, ext }) => {
const compressed = await compress(uncompressed);
this.emitFile({
type: "asset",
fileName: `${fileName}.${ext}`,
source: compressed,
});
});
},
);
await Promise.all(promises);
},
};
}
declare module "vite" {
interface ManifestChunk {
integrity: string;
}
}
// Custom plugin to make sure that each asset has an entry in the manifest
// This is needed so that the preloading & asset integrity generation works
// It also calculates integrity hashes for the assets
function augmentManifest(): PluginOption {
// Store a per-environment state, in case the build is run multiple times, like in watch mode
const state = new Map<Environment, Record<string, Promise<string>>>();
return {
name: "augment-manifest",
apply: "build",
enforce: "post",
perEnvironmentStartEndDuringDev: true,
buildStart() {
state.set(this.environment, {});
},
generateBundle(_outputOptions, bundle) {
const envState = state.get(this.environment);
if (!envState) throw new Error("No state for environment");
for (const [fileName, assetOrChunk] of Object.entries(bundle)) {
// Start calculating hash of the asset. We can let that run in the
// background
const source =
assetOrChunk.type === "asset"
? assetOrChunk.source
: assetOrChunk.code;
envState[fileName] = (async (): Promise<string> => {
const digest = await crypto.subtle.digest(
"SHA-384",
Buffer.from(source),
);
return `sha384-${Buffer.from(digest).toString("base64")}`;
})();
}
},
async writeBundle({ dir }): Promise<void> {
const envState = state.get(this.environment);
if (!envState) throw new Error("No state for environment");
state.delete(this.environment);
const manifestPath = resolve(dir, "manifest.json");
let manifestHandle: FileHandle;
try {
manifestHandle = await open(manifestPath, "r+");
} catch (error) {
// Manifest does not exist, nothing to do but still warn about
this.warn(`Failed to open manifest at ${manifestPath}: ${error}`);
return;
}
const rawManifest = await manifestHandle.readFile("utf-8");
const manifest = JSON.parse(rawManifest) as Manifest;
const existing: Set<string> = new Set();
const needs: Set<string> = new Set();
for (const chunk of Object.values(manifest)) {
existing.add(chunk.file);
chunk.integrity = await envState[chunk.file];
for (const css of chunk.css ?? []) needs.add(css);
for (const sub of chunk.assets ?? []) needs.add(sub);
}
const missing = Array.from(needs).filter((a) => !existing.has(a));
for (const asset of missing) {
manifest[asset] = {
file: asset,
integrity: await envState[asset],
};
}
// Overwrite the manifest with the augmented entries
// XXX: you'd think that doing `manifestHandle.writeFile` would work, as
// the docs says that it 'overwrites the file if it exists'. Turns out, it
// reuses the previous position from `readFile`, so that would append on
// the existing, so we have to use `write` with an explicit position.
// Truncating the file just in case the output is smaller than before.
await manifestHandle.truncate(0);
await manifestHandle.write(JSON.stringify(manifest, null, 2), 0, "utf-8");
await manifestHandle.close();
},
};
}
export default defineConfig((env) => ({
base: "./",
css: {
modules: {
localsConvention: "camelCaseOnly",
},
},
define: {
"import.meta.vitest": "undefined",
"process.env.NODE_ENV": JSON.stringify(env.mode),
},
build: {
manifest: "manifest.json",
assetsDir: "",
sourcemap: true,
target: browserslistToEsbuild(),
cssCodeSplit: true,
rollupOptions: {
// This uses all the files in the src/entrypoints directory as inputs
input: globSync(resolve(__dirname, "src/entrypoints/**/*.{css,ts,tsx}")),
},
},
plugins: [
codegen(),
tanstackRouter({
target: "react",
autoCodeSplitting: true,
verboseFileRoutes: false,
}),
react(),
augmentManifest(),
compression(),
i18nHotReload(),
],
server: {
base: "/account/",
proxy: {
// Routes mostly extracted from crates/router/src/endpoints.rs
"^/(|graphql.*|assets.*|\\.well-known.*|oauth2.*|login.*|logout.*|register.*|reauth.*|add-email.*|verify-email.*|change-password.*|consent.*|_matrix.*|complete-compat-sso.*|link.*|device.*|upstream.*|recover.*)$":
"http://127.0.0.1:8080",
},
},
test: {
globalSetup: "./vitest.global-setup.ts",
setupFiles: "./vitest.setup.ts",
coverage: {
provider: "v8",
src: ["./src/"],
exclude: ["**/gql/**", "**/*.d.ts", "**/*.stories.*"],
all: true,
reporter: ["text", "html", "lcov"],
},
},
}));