Skip to content

Commit

Permalink
Add --bundle option to web commands (#195)
Browse files Browse the repository at this point in the history
# Objective

Closes #68.

Adds the `--bundle` argument to the `bevy build web` and `bevy run web`
commands.
This will pack all files needed for the web into a single folder.
The location of the bundle will be `target/bevy_web/{profile}/{binary}`.

This makes it a lot easier to deploy the app e.g. on itch.io or a
generic web server.
It also unblocks the switch from `trunk` to the Bevy CLI for the Bevy 2D
template: <TheBevyFlock/bevy_new_2d#312>.

# Solution

- Add the `--bundle` option to the build and run web commands.
- Create a new `WebBundle` enum, which can represent a linked or packed
bundle:
- A linked bundle is what we use by default, which is optimized for dev.
It keeps e.g. the asset folder and WASM artifacts in their place to
avoid duplication and copy operations. When running the app, the local
web server will "link" together all the files and folders needed to run
the app.
- A packed bundle is a folder that contains all the necessary web
artifacts. They will be copied in the directory. This option is most
useful to deploy the web app.
  • Loading branch information
TimJentzsch authored Dec 29, 2024
1 parent 4e740c7 commit f96f113
Show file tree
Hide file tree
Showing 10 changed files with 265 additions and 56 deletions.
7 changes: 7 additions & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,8 @@ actix-web = "4.9.0"
# Opening the app in the browser
webbrowser = "1.0.2"

# Copying directories
fs_extra = "1.3.0"

# Optimizing Wasm binaries
wasm-opt = { version = "0.116.1", optional = true }
1 change: 1 addition & 0 deletions assets/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@

<script type="module">
// Starting the game
// The template uses `bevy_app.js`, which will be replaced by the name of the generated JS entrypoint when creating the local web server
import init from "./build/bevy_app.js";
init().catch((error) => {
if (
Expand Down
13 changes: 10 additions & 3 deletions src/build/args.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use clap::{Args, Subcommand};
use clap::{ArgAction, Args, Subcommand};

use crate::external_cli::{arg_builder::ArgBuilder, cargo::build::CargoBuildArgs};

Expand All @@ -16,7 +16,7 @@ pub struct BuildArgs {
impl BuildArgs {
/// Determine if the app is being built for the web.
pub(crate) fn is_web(&self) -> bool {
matches!(self.subcommand, Some(BuildSubcommands::Web))
matches!(self.subcommand, Some(BuildSubcommands::Web(_)))
}

/// Whether to build with optimizations.
Expand Down Expand Up @@ -44,5 +44,12 @@ impl BuildArgs {
#[derive(Debug, Subcommand)]
pub enum BuildSubcommands {
/// Build your app for the browser.
Web,
Web(BuildWebArgs),
}

#[derive(Debug, Args)]
pub struct BuildWebArgs {
// Bundle all web artifacts into a single folder.
#[arg(short = 'b', long = "bundle", action = ArgAction::SetTrue, default_value_t = false)]
pub create_packed_bundle: bool,
}
13 changes: 12 additions & 1 deletion src/build/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use args::BuildSubcommands;

use crate::{
external_cli::{cargo, rustup, wasm_bindgen, CommandHelpers},
run::select_run_binary,
web::bundle::{create_web_bundle, PackedBundle, WebBundle},
};

pub use self::args::BuildArgs;
Expand All @@ -10,7 +13,7 @@ mod args;
pub fn build(args: &BuildArgs) -> anyhow::Result<()> {
let cargo_args = args.cargo_args_builder();

if args.is_web() {
if let Some(BuildSubcommands::Web(web_args)) = &args.subcommand {
ensure_web_setup()?;

let metadata = cargo::metadata::metadata_with_args(["--no-deps"])?;
Expand All @@ -33,6 +36,14 @@ pub fn build(args: &BuildArgs) -> anyhow::Result<()> {
if args.is_release() {
crate::web::wasm_opt::optimize_bin(&bin_target)?;
}

if web_args.create_packed_bundle {
let web_bundle = create_web_bundle(&metadata, args.profile(), bin_target, true)?;

if let WebBundle::Packed(PackedBundle { path }) = &web_bundle {
println!("Created bundle at file://{}", path.display());
}
}
} else {
cargo::build::command().args(cargo_args).ensure_status()?;
}
Expand Down
4 changes: 4 additions & 0 deletions src/run/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,8 @@ pub struct RunWebArgs {
/// Open the app in the browser.
#[arg(short = 'o', long = "open", action = ArgAction::SetTrue, default_value_t = false)]
pub open: bool,

// Bundle all web artifacts into a single folder.
#[arg(short = 'b', long = "bundle", action = ArgAction::SetTrue, default_value_t = false)]
pub create_packed_bundle: bool,
}
16 changes: 15 additions & 1 deletion src/run/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::path::PathBuf;

use anyhow::Context;
use args::RunSubcommands;

use crate::{
Expand All @@ -8,6 +9,7 @@ use crate::{
cargo::{self, metadata::Metadata},
wasm_bindgen, CommandHelpers,
},
web::bundle::{create_web_bundle, PackedBundle, WebBundle},
};

pub use self::args::RunArgs;
Expand Down Expand Up @@ -43,6 +45,18 @@ pub fn run(args: &RunArgs) -> anyhow::Result<()> {
crate::web::wasm_opt::optimize_bin(&bin_target)?;
}

let web_bundle = create_web_bundle(
&metadata,
args.profile(),
bin_target,
web_args.create_packed_bundle,
)
.context("Failed to create web bundle")?;

if let WebBundle::Packed(PackedBundle { path }) = &web_bundle {
println!("Created bundle at file://{}", path.display());
}

let port = web_args.port;
let url = format!("http://localhost:{port}");

Expand All @@ -58,7 +72,7 @@ pub fn run(args: &RunArgs) -> anyhow::Result<()> {
println!("Open your app at <{url}>!");
}

serve::serve(bin_target, port)?;
serve::serve(web_bundle, port)?;
} else {
// For native builds, wrap `cargo run`
cargo::run::command().args(cargo_args).ensure_status()?;
Expand Down
91 changes: 40 additions & 51 deletions src/run/serve.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
//! Serving the app locally for the browser.
use actix_web::{rt, web, App, HttpResponse, HttpServer, Responder};
use std::path::Path;

use super::BinTarget;
use crate::web::bundle::{Index, LinkedBundle, PackedBundle, WebBundle};

/// Serve a static HTML file with the given content.
async fn serve_static_html(content: &'static str) -> impl Responder {
Expand All @@ -15,62 +14,52 @@ async fn serve_static_html(content: &'static str) -> impl Responder {
.body(content)
}

/// Create the default `index.html` if the user didn't provide one.
fn default_index(bin_target: &BinTarget) -> &'static str {
let template = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/web/index.html"
));

// Insert correct path to JS bindings
let index = template.replace(
"./build/bevy_app.js",
format!("./build/{}.js", bin_target.bin_name).as_str(),
);

// Only static strings can be served in the web app,
// so we leak the string memory to convert it to a static reference.
// PERF: This is assumed to be used only once and is needed for the rest of the app running
// time, making the memory leak acceptable.
Box::leak(index.into_boxed_str())
}

/// Launch a web server running the Bevy app.
pub(crate) fn serve(bin_target: BinTarget, port: u16) -> anyhow::Result<()> {
let index_html = default_index(&bin_target);

pub(crate) fn serve(web_bundle: WebBundle, port: u16) -> anyhow::Result<()> {
rt::System::new().block_on(
HttpServer::new(move || {
let mut app = App::new();
let bin_target = bin_target.clone();

// Serve the build artifacts at the `/build/*` route
// A custom `index.html` will have to call `/build/{bin_name}.js`
app = app.service(
actix_files::Files::new("/build", bin_target.artifact_directory.clone())
// This potentially includes artifacts which we will not need,
// but we can't add the bin name to the check due to lifetime requirements
.path_filter(move |path, _| {
path.file_stem().is_some_and(|stem| {
// Using `.starts_with` instead of equality, because of the `_bg` suffix
// of the WASM bindings
stem.to_string_lossy().starts_with(&bin_target.bin_name)
}) && (path.extension().is_some_and(|ext| ext == "js")
|| path.extension().is_some_and(|ext| ext == "wasm"))
}),
);
match web_bundle.clone() {
WebBundle::Packed(PackedBundle { path }) => {
app = app.service(actix_files::Files::new("/", path).index_file("index.html"));
}
WebBundle::Linked(LinkedBundle {
build_artifact_path,
wasm_file_name,
js_file_name,
index,
assets_path,
}) => {
// Serve the build artifacts at the `/build/*` route
// A custom `index.html` will have to call `/build/{bin_name}.js`
app = app.service(
actix_files::Files::new("/build", build_artifact_path)
// This potentially includes artifacts which we will not need,
// but we can't add the bin name to the check due to lifetime
// requirements
.path_filter(move |path, _| {
path.file_name() == Some(&js_file_name)
|| path.file_name() == Some(&wasm_file_name)
}),
);

// If the app has an assets folder, serve it under `/assets`
if Path::new("assets").exists() {
app = app.service(actix_files::Files::new("/assets", "./assets"))
}
// If the app has an assets folder, serve it under `/assets`
if let Some(assets_path) = assets_path {
app = app.service(actix_files::Files::new("/assets", assets_path))
}

if Path::new("web").exists() {
// Serve the contents of the `web` folder under `/`, if it exists
app = app.service(actix_files::Files::new("/", "./web").index_file("index.html"));
} else {
// If the user doesn't provide a custom web setup, serve a default `index.html`
app = app.route("/", web::get().to(|| serve_static_html(index_html)))
match index {
Index::Folder(path) => {
app = app.service(
actix_files::Files::new("/", path).index_file("index.html"),
);
}
Index::Static(contents) => {
app = app.route("/", web::get().to(move || serve_static_html(contents)))
}
}
}
}

app
Expand Down
Loading

0 comments on commit f96f113

Please sign in to comment.