feat: http asset sources

This commit is contained in:
Peter Hayman 2025-02-17 13:57:16 +11:00
parent ea578415e1
commit 46b568641e
13 changed files with 358 additions and 8 deletions

3
.gitignore vendored
View File

@ -1,6 +1,6 @@
# If your IDE needs additional project specific files, configure git to ignore them:
# https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files#configuring-ignored-files-for-all-repositories-on-your-computer
.vscode
# Rust build artifacts
target
crates/**/target
@ -22,6 +22,7 @@ Cargo.lock
assets/**/*.meta
crates/bevy_asset/imported_assets
imported_assets
.http-asset-cache
# Bevy Examples
example_showcase_config.ron

View File

@ -455,6 +455,12 @@ file_watcher = ["bevy_internal/file_watcher"]
# Enables watching in memory asset providers for Bevy Asset hot-reloading
embedded_watcher = ["bevy_internal/embedded_watcher"]
# Enables using assets from HTTP sources
http_source = ["bevy_internal/http_source"]
# Assets downloaded from HTTP sources are cached
http_source_cache = ["bevy_internal/http_source_cache"]
# Enable stepping-based debugging of Bevy systems
bevy_debug_stepping = ["bevy_internal/bevy_debug_stepping"]
@ -522,7 +528,7 @@ accesskit = "0.17"
smol = "2"
smol-macros = "0.1"
smol-hyper = "0.1"
ureq = { version = "2.10.1", features = ["json"] }
ureq = { version = "3", features = ["json"] }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen = { version = "0.2" }
@ -1737,12 +1743,24 @@ path = "examples/asset/extra_source.rs"
doc-scrape-examples = true
[package.metadata.example.extra_asset_source]
name = "Extra asset source"
name = "Extra Asset Source"
description = "Load an asset from a non-standard asset source"
category = "Assets"
# Uses non-standard asset path
wasm = false
[[example]]
name = "http_source"
path = "examples/asset/http_source.rs"
doc-scrape-examples = true
required-features = ["http_source"]
[package.metadata.example.http_source]
name = "HTTP Asset Source"
description = "Load an asset from a http source"
category = "Assets"
wasm = true
[[example]]
name = "hot_asset_reloading"
path = "examples/asset/hot_asset_reloading.rs"

View File

@ -14,6 +14,8 @@ keywords = ["bevy"]
file_watcher = ["notify-debouncer-full", "watch"]
embedded_watcher = ["file_watcher"]
multi_threaded = ["bevy_tasks/multi_threaded"]
http_source = ["ureq", "rustls"]
http_source_cache = []
asset_processor = []
watch = []
trace = []
@ -68,6 +70,18 @@ uuid = { version = "1.13.1", default-features = false, features = ["js"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
notify-debouncer-full = { version = "0.5.0", optional = true }
# updating ureq: while ureq is semver stable rustls is not, meaning unlikely but possible breaking changes on minor releases. https://github.com/bevyengine/bevy/pull/16366#issuecomment-2572890794
ureq = { version = "3.0.4", optional = true, default-features = false, features = [
"rustls-no-provider",
"gzip",
"json",
] }
rustls = { version = "0.23", optional = true, default-features = false, features = [
"aws_lc_rs",
"logging",
"std",
"tls12",
] }
[dev-dependencies]
bevy_log = { path = "../bevy_log", version = "0.16.0-dev" }

View File

@ -0,0 +1,272 @@
use crate::io::{AssetReader, AssetReaderError, Reader};
use crate::io::{AssetSource, PathStream};
use crate::AssetApp;
use alloc::boxed::Box;
use bevy_app::App;
use bevy_tasks::ConditionalSendFuture;
use std::path::{Path, PathBuf};
/// Adds the `http` and `https` asset sources to the app.
/// Any asset path that begins with `http` or `https` will be loaded from the web
/// via `fetch`(wasm) or `ureq`(native).
pub fn http_source_plugin(app: &mut App) {
app.register_asset_source(
"http",
AssetSource::build().with_reader(|| Box::new(HttpSourceAssetReader::Http)),
);
app.register_asset_source(
"https",
AssetSource::build().with_reader(|| Box::new(HttpSourceAssetReader::Https)),
);
}
/// Asset reader that treats paths as urls to load assets from.
pub enum HttpSourceAssetReader {
/// Unencrypted connections.
Http,
/// Use TLS for setting up connections.
Https,
}
impl HttpSourceAssetReader {
fn make_uri(&self, path: &Path) -> PathBuf {
PathBuf::from(match self {
Self::Http => "http://",
Self::Https => "https://",
})
.join(path)
}
/// See [`crate::io::get_meta_path`]
fn make_meta_uri(&self, path: &Path) -> Option<PathBuf> {
let mut uri = self.make_uri(path);
let mut extension = path.extension()?.to_os_string();
extension.push(".meta");
uri.set_extension(extension);
Some(uri)
}
}
#[cfg(target_arch = "wasm32")]
async fn get<'a>(path: PathBuf) -> Result<Box<dyn Reader>, AssetReaderError> {
use crate::io::wasm::HttpWasmAssetReader;
HttpWasmAssetReader::new("")
.fetch_bytes(path)
.await
.map(|r| Box::new(r) as Box<dyn Reader>)
}
#[cfg(not(target_arch = "wasm32"))]
async fn get(path: PathBuf) -> Result<Box<dyn Reader>, AssetReaderError> {
use crate::io::VecReader;
use alloc::{boxed::Box, vec::Vec};
use bevy_platform_support::sync::LazyLock;
use std::io::{self, BufReader, Read};
let str_path = path.to_str().ok_or_else(|| {
AssetReaderError::Io(
io::Error::new(
io::ErrorKind::Other,
std::format!("non-utf8 path: {}", path.display()),
)
.into(),
)
})?;
#[cfg(feature = "http_source_cache")]
if let Some(data) = http_asset_cache::try_load_from_cache(str_path)? {
return Ok(Box::new(VecReader::new(data)));
}
use ureq::Agent;
static AGENT: LazyLock<Agent> = LazyLock::new(|| {
use alloc::sync::Arc;
use ureq::{
tls::{TlsConfig, TlsProvider},
Agent,
};
let crypto = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
Agent::config_builder()
.tls_config(
TlsConfig::builder()
.provider(TlsProvider::Rustls)
// requires rustls or rustls-no-provider feature
.unversioned_rustls_crypto_provider(crypto)
.build(),
)
.build()
.new_agent()
});
match AGENT.get(str_path).call() {
Ok(mut response) => {
// let mut reader = response.into_reader();
let mut reader = BufReader::new(response.body_mut().with_config().reader());
let mut buffer = Vec::new();
reader.read_to_end(&mut buffer)?;
#[cfg(feature = "http_source_cache")]
http_asset_cache::save_to_cache(str_path, &buffer)?;
Ok(Box::new(VecReader::new(buffer)))
}
// ureq considers all >=400 status codes as errors
Err(ureq::Error::StatusCode(code)) => {
if code == 404 {
Err(AssetReaderError::NotFound(path))
} else {
Err(AssetReaderError::HttpError(code))
}
}
Err(err) => Err(AssetReaderError::Io(
io::Error::new(
io::ErrorKind::Other,
std::format!(
"unexpected error while loading asset {}: {}",
path.display(),
err
),
)
.into(),
)),
}
}
impl AssetReader for HttpSourceAssetReader {
fn read<'a>(
&'a self,
path: &'a Path,
) -> impl ConditionalSendFuture<Output = Result<Box<dyn Reader>, AssetReaderError>> {
get(self.make_uri(path))
}
async fn read_meta<'a>(&'a self, path: &'a Path) -> Result<Box<dyn Reader>, AssetReaderError> {
match self.make_meta_uri(path) {
Some(uri) => get(uri).await,
None => Err(AssetReaderError::NotFound(
"source path has no extension".into(),
)),
}
}
async fn is_directory<'a>(&'a self, _path: &'a Path) -> Result<bool, AssetReaderError> {
Ok(false)
}
async fn read_directory<'a>(
&'a self,
path: &'a Path,
) -> Result<Box<PathStream>, AssetReaderError> {
Err(AssetReaderError::NotFound(self.make_uri(path)))
}
}
/// A naive implementation of an HTTP asset cache that never invalidates.
/// `ureq` currently does not support caching, so this is a simple workaround.
/// It should eventually be replaced by `http-cache` or similar, see [tracking issue](https://github.com/06chaynes/http-cache/issues/91)
#[cfg(feature = "http_source_cache")]
mod http_asset_cache {
use alloc::string::String;
use alloc::vec::Vec;
use core::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::PathBuf;
const CACHE_DIR: &str = ".http-asset-cache";
fn url_to_hash(url: &str) -> String {
let mut hasher = DefaultHasher::new();
url.hash(&mut hasher);
std::format!("{:x}", hasher.finish())
}
pub fn try_load_from_cache(url: &str) -> Result<Option<Vec<u8>>, io::Error> {
let filename = url_to_hash(url);
let cache_path = PathBuf::from(CACHE_DIR).join(&filename);
if cache_path.exists() {
let mut file = File::open(&cache_path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
Ok(Some(buffer))
} else {
Ok(None)
}
}
pub fn save_to_cache(url: &str, data: &[u8]) -> Result<(), io::Error> {
let filename = url_to_hash(url);
let cache_path = PathBuf::from(CACHE_DIR).join(&filename);
fs::create_dir_all(CACHE_DIR).ok();
let mut cache_file = File::create(&cache_path)?;
cache_file.write_all(data)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn make_http_uri() {
assert_eq!(
HttpSourceAssetReader::Http
.make_uri(Path::new("example.com/favicon.png"))
.to_str()
.unwrap(),
"http://example.com/favicon.png"
);
}
#[test]
fn make_https_uri() {
assert_eq!(
HttpSourceAssetReader::Https
.make_uri(Path::new("example.com/favicon.png"))
.to_str()
.unwrap(),
"https://example.com/favicon.png"
);
}
#[test]
fn make_http_meta_uri() {
assert_eq!(
HttpSourceAssetReader::Http
.make_meta_uri(Path::new("example.com/favicon.png"))
.expect("cannot create meta uri")
.to_str()
.unwrap(),
"http://example.com/favicon.png.meta"
);
}
#[test]
fn make_https_meta_uri() {
assert_eq!(
HttpSourceAssetReader::Https
.make_meta_uri(Path::new("example.com/favicon.png"))
.expect("cannot create meta uri")
.to_str()
.unwrap(),
"https://example.com/favicon.png.meta"
);
}
#[test]
fn make_https_without_extension_meta_uri() {
assert_eq!(
HttpSourceAssetReader::Https.make_meta_uri(Path::new("example.com/favicon")),
None
);
}
}

View File

@ -46,7 +46,8 @@ pub enum AssetReaderError {
Io(Arc<std::io::Error>),
/// The HTTP request completed but returned an unhandled [HTTP response status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status).
/// If the request fails before getting a status code (e.g. request timeout, interrupted connection, etc), expect [`AssetReaderError::Io`].
/// - If the request returns a 404 error, expect [`AssetReaderError::NotFound`].
/// - If the request fails before getting a status code (e.g. request timeout, interrupted connection, etc), expect [`AssetReaderError::Io`].
#[error("Encountered HTTP status {0:?} when loading asset")]
HttpError(u16),
}

View File

@ -52,7 +52,11 @@ fn js_value_to_err(context: &str) -> impl FnOnce(JsValue) -> std::io::Error + '_
}
impl HttpWasmAssetReader {
async fn fetch_bytes<'a>(&self, path: PathBuf) -> Result<impl Reader, AssetReaderError> {
// Also used by HttpSourceAssetReader
pub(crate) async fn fetch_bytes<'a>(
&self,
path: PathBuf,
) -> Result<impl Reader, AssetReaderError> {
// The JS global scope includes a self-reference via a specializing name, which can be used to determine the type of global context available.
let global: Global = js_sys::global().unchecked_into();
let promise = if !global.window().is_undefined() {

View File

@ -186,6 +186,9 @@ mod reflect;
mod render_asset;
mod server;
#[cfg(feature = "http_source")]
mod http_source;
pub use assets::*;
pub use bevy_asset_macros::Asset;
pub use direct_access_ext::DirectAssetAccessExt;
@ -324,6 +327,9 @@ impl AssetPlugin {
impl Plugin for AssetPlugin {
fn build(&self, app: &mut App) {
#[cfg(feature = "http_source")]
app.add_plugins(http_source::http_source_plugin);
let embedded = EmbeddedAssetRegistry::default();
{
let mut sources = app

View File

@ -205,6 +205,12 @@ debug_glam_assert = ["bevy_math/debug_glam_assert"]
default_font = ["bevy_text?/default_font"]
# Enables using assets from HTTP sources
http_source = ["bevy_asset?/http_source"]
# Assets downloaded from HTTP sources are cached
http_source_cache = ["bevy_asset?/http_source_cache"]
# Enables the built-in asset processor for processed assets.
asset_processor = ["bevy_asset?/asset_processor"]

View File

@ -18,7 +18,9 @@ allow = [
"ISC",
"MIT",
"MIT-0",
"MPL-2.0",
"Unlicense",
"OpenSSL",
"Zlib",
]

View File

@ -78,6 +78,8 @@ The default feature set enables most of the expected features of a game engine,
|ghost_nodes|Experimental support for nodes that are ignored for UI layouting|
|gif|GIF image format support|
|glam_assert|Enable assertions to check the validity of parameters passed to glam|
|http_source|Enables using assets from HTTP sources|
|http_source_cache|Assets downloaded from HTTP sources are cached|
|ico|ICO image format support|
|ios_simulator|Enable support for the ios_simulator by downgrading some rendering capabilities|
|jpeg|JPEG image format support|

View File

@ -248,7 +248,8 @@ Example | Description
[Custom Asset](../examples/asset/custom_asset.rs) | Implements a custom asset loader
[Custom Asset IO](../examples/asset/custom_asset_reader.rs) | Implements a custom AssetReader
[Embedded Asset](../examples/asset/embedded_asset.rs) | Embed an asset in the application binary and load it
[Extra asset source](../examples/asset/extra_source.rs) | Load an asset from a non-standard asset source
[Extra Asset Source](../examples/asset/extra_source.rs) | Load an asset from a non-standard asset source
[HTTP Asset Source](../examples/asset/http_source.rs) | Load an asset from a http source
[Hot Reloading of Assets](../examples/asset/hot_asset_reloading.rs) | Demonstrates automatic reloading of assets when modified on disk
[Mult-asset synchronization](../examples/asset/multi_asset_sync.rs) | Demonstrates how to wait for multiple assets to be loaded.
[Repeated texture configuration](../examples/asset/repeated_texture.rs) | How to configure the texture to repeat instead of the default clamp to edges

View File

@ -0,0 +1,22 @@
//! Example usage of the `http` asset source to load assets from the web.
//!
//! Run with the feature `http_source`, and optionally `http_source_cache`
//! for a simple caching mechanism that never invalidates.
//!
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.run();
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn(Camera2d);
commands.spawn(
// Simply use a url where you would normally use an asset folder relative path
Sprite::from_image(asset_server.load("https://raw.githubusercontent.com/bevyengine/bevy/refs/heads/main/assets/branding/bevy_bird_dark.png"))
);
}

View File

@ -49,7 +49,7 @@ fn main() -> AnyhowResult<()> {
let req = BrpRequest {
jsonrpc: String::from("2.0"),
method: String::from(BRP_QUERY_METHOD),
id: Some(ureq::json!(1)),
id: Some(serde_json::json!(1)),
params: Some(
serde_json::to_value(BrpQueryParams {
data: BrpQuery {
@ -66,7 +66,8 @@ fn main() -> AnyhowResult<()> {
let res = ureq::post(&url)
.send_json(req)?
.into_json::<serde_json::Value>()?;
.body_mut()
.read_json::<serde_json::Value>()?;
println!("{:#}", res);