Skip to content

Move asset hashing into a linker intercept step #3988

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 32 additions & 6 deletions packages/cli-opt/src/css.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::path::Path;
use std::{hash::Hasher, path::Path};

use anyhow::Context;
use codemap::SpanLoc;
Expand Down Expand Up @@ -78,12 +78,11 @@ pub(crate) fn minify_css(css: &str) -> anyhow::Result<String> {
Ok(res.code)
}

/// Process an scss/sass file into css.
pub(crate) fn process_scss(
/// Compile scss with grass
pub(crate) fn compile_scss(
scss_options: &CssAssetOptions,
source: &Path,
output_path: &Path,
) -> anyhow::Result<()> {
) -> anyhow::Result<String> {
let style = match scss_options.minified() {
true => OutputStyle::Compressed,
false => OutputStyle::Expanded,
Expand All @@ -94,7 +93,18 @@ pub(crate) fn process_scss(
.quiet(false)
.logger(&ScssLogger {});

let css = grass::from_path(source, &options)?;
let css = grass::from_path(source, &options)
.with_context(|| format!("Failed to compile scss file: {}", source.display()))?;
Ok(css)
}

/// Process an scss/sass file into css.
pub(crate) fn process_scss(
scss_options: &CssAssetOptions,
source: &Path,
output_path: &Path,
) -> anyhow::Result<()> {
let css = compile_scss(scss_options, source)?;
let minified = minify_css(&css)?;

std::fs::write(output_path, minified).with_context(|| {
Expand Down Expand Up @@ -131,3 +141,19 @@ impl grass::Logger for ScssLogger {
);
}
}

/// Hash the inputs to the scss file
pub(crate) fn hash_scss(
scss_options: &CssAssetOptions,
source: &Path,
hasher: &mut impl Hasher,
) -> anyhow::Result<()> {
// Grass doesn't expose the ast for us to traverse the imports in the file. Instead of parsing scss ourselves
// we just hash the expanded version of the file for now
let css = compile_scss(scss_options, source)?;

// Hash the compiled css
hasher.write(css.as_bytes());

Ok(())
}
113 changes: 71 additions & 42 deletions packages/cli-opt/src/file.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use anyhow::Context;
use manganis::FolderAssetOptions;
use manganis_core::{AssetOptions, CssAssetOptions, ImageAssetOptions, JsAssetOptions};
use std::path::Path;

Expand Down Expand Up @@ -33,7 +34,7 @@ pub(crate) fn process_file_to_with_options(
}
if let Some(parent) = output_path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)?;
std::fs::create_dir_all(parent).context("Failed to create directory")?;
}
}

Expand All @@ -47,60 +48,88 @@ pub(crate) fn process_file_to_with_options(
.unwrap_or_default()
.to_string_lossy()
));
let resolved_options = resolve_asset_options(source, options);

match options {
AssetOptions::Unknown => match source.extension().map(|e| e.to_string_lossy()).as_deref() {
Some("css") => {
process_css(&CssAssetOptions::new(), source, &temp_path)?;
}
Some("scss" | "sass") => {
process_scss(&CssAssetOptions::new(), source, &temp_path)?;
}
Some("js") => {
process_js(&JsAssetOptions::new(), source, &temp_path, !in_folder)?;
}
Some("json") => {
process_json(source, &temp_path)?;
}
Some("jpg" | "jpeg" | "png" | "webp" | "avif") => {
process_image(&ImageAssetOptions::new(), source, &temp_path)?;
}
Some(_) | None => {
if source.is_dir() {
process_folder(source, &temp_path)?;
} else {
let source_file = std::fs::File::open(source)?;
let mut reader = std::io::BufReader::new(source_file);
let output_file = std::fs::File::create(&temp_path)?;
let mut writer = std::io::BufWriter::new(output_file);
std::io::copy(&mut reader, &mut writer).with_context(|| {
format!(
"Failed to write file to output location: {}",
temp_path.display()
)
})?;
}
}
},
AssetOptions::Css(options) => {
match &resolved_options {
ResolvedAssetType::Css(options) => {
process_css(options, source, &temp_path)?;
}
AssetOptions::Js(options) => {
ResolvedAssetType::Scss(options) => {
process_scss(options, source, &temp_path)?;
}
ResolvedAssetType::Js(options) => {
process_js(options, source, &temp_path, !in_folder)?;
}
AssetOptions::Image(options) => {
ResolvedAssetType::Image(options) => {
process_image(options, source, &temp_path)?;
}
AssetOptions::Folder(_) => {
ResolvedAssetType::Json => {
process_json(source, &temp_path)?;
}
ResolvedAssetType::Folder(_) => {
process_folder(source, &temp_path)?;
}
_ => {
tracing::warn!("Unknown asset options: {:?}", options);
ResolvedAssetType::File => {
let source_file = std::fs::File::open(source)?;
let mut reader = std::io::BufReader::new(source_file);
let output_file = std::fs::File::create(&temp_path)?;
let mut writer = std::io::BufWriter::new(output_file);
std::io::copy(&mut reader, &mut writer).with_context(|| {
format!(
"Failed to write file to output location: {}",
temp_path.display()
)
})?;
}
}

// If everything was successful, rename the temp file to the final output path
std::fs::rename(temp_path, output_path)?;
std::fs::rename(temp_path, output_path).context("Failed to rename output file")?;

Ok(())
}

pub(crate) enum ResolvedAssetType {
/// An image asset
Image(ImageAssetOptions),
/// A css asset
Css(CssAssetOptions),
/// A SCSS asset
Scss(CssAssetOptions),
/// A javascript asset
Js(JsAssetOptions),
/// A json asset
Json,
/// A folder asset
Folder(FolderAssetOptions),
/// A generic file
File,
}

pub(crate) fn resolve_asset_options(source: &Path, options: &AssetOptions) -> ResolvedAssetType {
match options {
AssetOptions::Image(image) => ResolvedAssetType::Image(image.clone()),
AssetOptions::Css(css) => ResolvedAssetType::Css(css.clone()),
AssetOptions::Js(js) => ResolvedAssetType::Js(js.clone()),
AssetOptions::Folder(folder) => ResolvedAssetType::Folder(folder.clone()),
AssetOptions::Unknown => resolve_unknown_asset_options(source),
_ => {
tracing::warn!("Unknown asset options... you may need to update the Dioxus CLI. Defaulting to a generic file: {:?}", options);
resolve_unknown_asset_options(source)
}
}
}

fn resolve_unknown_asset_options(source: &Path) -> ResolvedAssetType {
match source.extension().map(|e| e.to_string_lossy()).as_deref() {
Some("scss" | "sass") => ResolvedAssetType::Scss(CssAssetOptions::new()),
Some("css") => ResolvedAssetType::Css(CssAssetOptions::new()),
Some("js") => ResolvedAssetType::Js(JsAssetOptions::new()),
Some("json") => ResolvedAssetType::Json,
Some("jpg" | "jpeg" | "png" | "webp" | "avif") => {
ResolvedAssetType::Image(ImageAssetOptions::new())
}
_ if source.is_dir() => ResolvedAssetType::Folder(FolderAssetOptions::new()),
_ => ResolvedAssetType::File,
}
}
106 changes: 106 additions & 0 deletions packages/cli-opt/src/hash.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! Utilities for creating hashed paths to assets in Manganis. This module defines [`AssetHash`] which is used to create a hashed path to an asset in both the CLI and the macro.

use std::{hash::Hasher, io::Read, path::Path};

use crate::{
css::hash_scss,
file::{resolve_asset_options, ResolvedAssetType},
js::hash_js,
};
use manganis::AssetOptions;

/// The opaque hash type manganis uses to identify assets. Each time an asset or asset options change, this hash will
/// change. This hash is included in the URL of the bundled asset for cache busting.
pub struct AssetHash {
/// We use a wrapper type here to hide the exact size of the hash so we can switch to a sha hash in a minor version bump
hash: [u8; 8],
}

impl AssetHash {
/// Create a new asset hash
const fn new(hash: u64) -> Self {
Self {
hash: hash.to_le_bytes(),
}
}

/// Get the hash bytes
pub const fn bytes(&self) -> &[u8] {
&self.hash
}

/// Create a new asset hash for a file. The input file to this function should be fully resolved
pub fn hash_file_contents(
options: &AssetOptions,
file_path: &Path,
) -> anyhow::Result<AssetHash> {
hash_file(options, file_path)
}
}

/// Process a specific file asset with the given options reading from the source and writing to the output path
fn hash_file(options: &AssetOptions, source: &Path) -> anyhow::Result<AssetHash> {
// Create a hasher
let mut hash = std::collections::hash_map::DefaultHasher::new();
hash_file_with_options(options, source, &mut hash, false)?;

let hash = hash.finish();
Ok(AssetHash::new(hash))
}

/// Process a specific file asset with additional options
pub(crate) fn hash_file_with_options(
options: &AssetOptions,
source: &Path,
hasher: &mut impl Hasher,
in_folder: bool,
) -> anyhow::Result<()> {
let resolved_options = resolve_asset_options(source, options);

match &resolved_options {
// Scss and JS can import files during the bundling process. We need to hash
// both the files themselves and any imports they have
ResolvedAssetType::Scss(options) => {
hash_scss(options, source, hasher)?;
}
ResolvedAssetType::Js(options) => {
hash_js(options, source, hasher, !in_folder)?;
}

// Otherwise, we can just hash the file contents
ResolvedAssetType::Css(_)
| ResolvedAssetType::Image(_)
| ResolvedAssetType::Json
| ResolvedAssetType::File => {
hash_file_contents(source, hasher)?;
}
// Or the folder contents recursively
ResolvedAssetType::Folder(_) => {
let files = std::fs::read_dir(source)?;
for file in files.flatten() {
let path = file.path();
hash_file_with_options(&options, &path, hasher, true)?;
}
}
}

Ok(())
}

pub(crate) fn hash_file_contents(source: &Path, hasher: &mut impl Hasher) -> anyhow::Result<()> {
// Otherwise, open the file to get its contents
let mut file = std::fs::File::open(source)?;

// We add a hash to the end of the file so it is invalidated when the bundled version of the file changes
// The hash includes the file contents, the options, and the version of manganis. From the macro, we just
// know the file contents, so we only include that hash
let mut buffer = [0; 8192];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.write(&buffer[..read]);
}
Ok(())
}
Loading