feat(bundle): support html entrypoint (#29856)

For instance

`deno bundle --outdir dist index.html`

It will find scripts referenced in the html, bundle them, and then
update the paths in index.html for the bundled assets.

Right now it doesn't handle other assets (from `link` elements), but it
could
This commit is contained in:
Nathan Whitaker 2025-09-09 12:18:10 -07:00 committed by GitHub
parent 41ff38ae65
commit 4e4bbf2fcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1480 additions and 74 deletions

143
Cargo.lock generated
View file

@ -871,9 +871,9 @@ dependencies = [
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.0" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]] [[package]]
name = "cfg_aliases" name = "cfg_aliases"
@ -1437,6 +1437,29 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25670139e591f1c2869eb8d0d977028f8d05e859132b4c874ecd02a00d3c9174" checksum = "25670139e591f1c2869eb8d0d977028f8d05e859132b4c874ecd02a00d3c9174"
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn 2.0.87",
]
[[package]] [[package]]
name = "ctr" name = "ctr"
version = "0.9.2" version = "0.9.2"
@ -1585,6 +1608,7 @@ dependencies = [
"bincode", "bincode",
"boxed_error", "boxed_error",
"bytes", "bytes",
"bytes-str",
"capacity_builder", "capacity_builder",
"chrono", "chrono",
"clap", "clap",
@ -1650,6 +1674,7 @@ dependencies = [
"libsui", "libsui",
"libz-sys", "libz-sys",
"log", "log",
"lol_html",
"lsp-types", "lsp-types",
"malva", "malva",
"markup_fmt", "markup_fmt",
@ -1699,6 +1724,7 @@ dependencies = [
"tracing", "tracing",
"tracing-opentelemetry", "tracing-opentelemetry",
"tracing-subscriber", "tracing-subscriber",
"twox-hash 2.1.0",
"typed-arena", "typed-arena",
"unicode-width 0.1.13", "unicode-width 0.1.13",
"uuid", "uuid",
@ -2338,7 +2364,7 @@ dependencies = [
"anyhow", "anyhow",
"deno_ast", "deno_ast",
"deno_semver", "deno_semver",
"derive_more", "derive_more 0.99.17",
"if_chain", "if_chain",
"log", "log",
"once_cell", "once_cell",
@ -3391,6 +3417,26 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.87",
]
[[package]] [[package]]
name = "deunicode" name = "deunicode"
version = "1.4.3" version = "1.4.3"
@ -3656,6 +3702,21 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "dtoa"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]] [[package]]
name = "dunce" name = "dunce"
version = "1.0.5" version = "1.0.5"
@ -3867,14 +3928,13 @@ checksum = "31ae425815400e5ed474178a7a22e275a9687086a12ca63ec793ff292d8fdae8"
[[package]] [[package]]
name = "esbuild_client" name = "esbuild_client"
version = "0.6.0" version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b3d661f00dac26314d27da56eb41e0e43f270251fe8c53686bb1e32205863" checksum = "97ec79be0f4b20864729b38953ad77c7f347e436a7532c5f332dce9736aa4b5c"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"deno_unsync", "deno_unsync",
"derive_builder",
"indexmap 2.9.0", "indexmap 2.9.0",
"log", "log",
"parking_lot", "parking_lot",
@ -4332,6 +4392,15 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "generator" name = "generator"
version = "0.8.4" version = "0.8.4"
@ -5832,6 +5901,24 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "lol_html"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63d49c99bfbf3400dd6450e516515b7014fcb49b5cb533f4b725a00c1462a36"
dependencies = [
"bitflags 2.9.3",
"cfg-if",
"cssparser",
"encoding_rs",
"hashbrown 0.15.5",
"memchr",
"mime",
"precomputed-hash",
"selectors",
"thiserror 2.0.12",
]
[[package]] [[package]]
name = "loom" name = "loom"
version = "0.7.2" version = "0.7.2"
@ -6841,6 +6928,16 @@ dependencies = [
"phf_shared", "phf_shared",
] ]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]] [[package]]
name = "phf_generator" name = "phf_generator"
version = "0.11.2" version = "0.11.2"
@ -7002,6 +7099,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]] [[package]]
name = "prefix-trie" name = "prefix-trie"
version = "0.7.0" version = "0.7.0"
@ -8014,6 +8117,25 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "selectors"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3df44ba8a7ca7a4d28c589e04f526266ed76b6cc556e33fe69fa25de31939a65"
dependencies = [
"bitflags 2.9.3",
"cssparser",
"derive_more 2.0.1",
"fxhash",
"log",
"new_debug_unreachable",
"phf",
"phf_codegen",
"precomputed-hash",
"servo_arc",
"smallvec",
]
[[package]] [[package]]
name = "semver" name = "semver"
version = "0.9.0" version = "0.9.0"
@ -8151,6 +8273,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "servo_arc"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "204ea332803bd95a0b60388590d59cf6468ec9becf626e2451f1d26a1d972de4"
dependencies = [
"stable_deref_trait",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"

View file

@ -103,6 +103,7 @@ base64.workspace = true
bincode.workspace = true bincode.workspace = true
boxed_error.workspace = true boxed_error.workspace = true
bytes.workspace = true bytes.workspace = true
bytes-str = "0.2.5"
capacity_builder.workspace = true capacity_builder.workspace = true
chrono = { workspace = true, features = ["now"] } chrono = { workspace = true, features = ["now"] }
clap = { workspace = true, features = ["env", "string", "wrap_help", "error-context"] } clap = { workspace = true, features = ["env", "string", "wrap_help", "error-context"] }
@ -120,7 +121,7 @@ dprint-plugin-json.workspace = true
dprint-plugin-jupyter.workspace = true dprint-plugin-jupyter.workspace = true
dprint-plugin-markdown.workspace = true dprint-plugin-markdown.workspace = true
dprint-plugin-typescript.workspace = true dprint-plugin-typescript.workspace = true
esbuild_client = { version = "0.6.0", features = ["serde"] } esbuild_client = { version = "0.7.1", features = ["serde"] }
fancy-regex.workspace = true fancy-regex.workspace = true
faster-hex.workspace = true faster-hex.workspace = true
# If you disable the default __vendored_zlib_ng feature above, you _must_ be able to link against `-lz`. # If you disable the default __vendored_zlib_ng feature above, you _must_ be able to link against `-lz`.
@ -139,6 +140,7 @@ lazy-regex.workspace = true
libc.workspace = true libc.workspace = true
libz-sys.workspace = true libz-sys.workspace = true
log = { workspace = true, features = ["serde"] } log = { workspace = true, features = ["serde"] }
lol_html = "2.6.0"
lsp-types.workspace = true lsp-types.workspace = true
malva.workspace = true malva.workspace = true
markup_fmt.workspace = true markup_fmt.workspace = true
@ -181,6 +183,7 @@ tower-lsp.workspace = true
tracing = { workspace = true, features = ["log"], optional = true } tracing = { workspace = true, features = ["log"], optional = true }
tracing-opentelemetry = { workspace = true, optional = true } tracing-opentelemetry = { workspace = true, optional = true }
tracing-subscriber = { workspace = true, features = ["env-filter"], optional = true } tracing-subscriber = { workspace = true, features = ["env-filter"], optional = true }
twox-hash.workspace = true
typed-arena.workspace = true typed-arena.workspace = true
unicode-width.workspace = true unicode-width.workspace = true
uuid = { workspace = true, features = ["serde"] } uuid = { workspace = true, features = ["serde"] }

451
cli/tools/bundle/html.rs Normal file
View file

@ -0,0 +1,451 @@
// Copyright 2018-2025 the Deno authors. MIT license.
use std::borrow::Cow;
use std::cell::Cell;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use capacity_builder::StringBuilder;
use deno_core::anyhow;
use deno_core::error::AnyError;
use lol_html::element;
use lol_html::html_content::ContentType as LolContentType;
use crate::tools::bundle::OutputFile;
#[derive(Debug, Clone)]
pub struct Script {
pub src: Option<String>,
pub is_async: bool,
pub is_module: bool,
pub resolved_path: Option<PathBuf>,
}
struct Attr<'a> {
name: Cow<'static, str>,
value: Option<Cow<'a, str>>,
}
impl<'a> Attr<'a> {
fn new(
name: impl Into<Cow<'static, str>>,
value: Option<Cow<'a, str>>,
) -> Self {
Self {
name: name.into(),
value,
}
}
fn write_out<'s>(&'s self, out: &mut StringBuilder<'s>)
where
'a: 's,
{
out.append(&self.name);
if let Some(value) = &self.value {
out.append("=\"");
out.append(value);
out.append('"');
}
}
}
fn write_attr_list<'a, 's>(attrs: &'s [Attr<'a>], out: &mut StringBuilder<'s>)
where
'a: 's,
{
if attrs.is_empty() {
return;
}
out.append(' ');
for item in attrs.iter().take(attrs.len() - 1) {
item.write_out(out);
out.append(' ');
}
attrs.last().unwrap().write_out(out);
}
impl Script {
pub fn to_element_string(&self) -> String {
let mut attrs = Vec::new();
if let Some(src) = &self.src {
attrs.push(Attr::new("src", Some(Cow::Borrowed(src))));
}
if self.is_async {
attrs.push(Attr::new("async", None));
}
if self.is_module {
attrs.push(Attr::new("type", Some("module".into())));
}
attrs.push(Attr::new("crossorigin", None));
StringBuilder::build(|out| {
out.append("<script");
write_attr_list(&attrs, out);
out.append("></script>");
})
.unwrap()
}
}
struct NoOutput;
impl lol_html::OutputSink for NoOutput {
fn handle_chunk(&mut self, _: &[u8]) {}
}
fn collect_scripts(doc: &str) -> Result<Vec<Script>, AnyError> {
let mut scripts = Vec::new();
let mut rewriter = lol_html::HtmlRewriter::new(
lol_html::Settings {
element_content_handlers: vec![element!("script[src]", |el| {
let is_ignored =
el.has_attribute("deno-ignore") || el.has_attribute("vite-ignore");
if is_ignored {
return Ok(());
}
let typ = el.get_attribute("type");
let (Some("module") | None) = typ.as_deref() else {
return Ok(());
};
let src = el.get_attribute("src").unwrap();
let is_async = el.has_attribute("async");
let is_module = matches!(typ.as_deref(), Some("module"));
scripts.push(Script {
src: Some(src),
is_async,
is_module,
resolved_path: None,
});
Ok(())
})],
..lol_html::Settings::new()
},
NoOutput,
);
rewriter.write(doc.as_bytes())?;
rewriter.end()?;
Ok(scripts)
}
#[derive(Debug, Clone)]
pub struct HtmlEntrypoint {
pub path: PathBuf,
pub scripts: Vec<Script>,
pub temp_module: String,
pub contents: String,
pub entry_name: String,
pub virtual_module_path: PathBuf,
}
const VIRTUAL_ENTRY_SUFFIX: &str = ".deno-bundle-html.entry";
// Helper to create a filesystem-friendly name based on a path
fn sanitize_entry_name(cwd: &Path, path: &Path) -> String {
let rel =
pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf());
let stem = rel
.with_extension("")
.to_string_lossy()
.replace(['\\', '/', ':'], "_");
if stem.is_empty() {
"entry".to_string()
} else {
stem
}
}
fn parse_html_entrypoint(
cwd: &Path,
path: &Path,
contents: String,
) -> anyhow::Result<HtmlEntrypoint> {
let mut scripts = collect_scripts(&contents)?;
let mut temp_module = String::new();
for script in &mut scripts {
if let Some(src) = &mut script.src {
let src = src.trim_start_matches('/');
let path = path.parent().unwrap_or(Path::new("")).join(src);
let url = deno_path_util::url_from_file_path(&path)?;
temp_module.push_str(&format!("import \"{}\";\n", url));
script.resolved_path = Some(path);
}
}
let entry_name = sanitize_entry_name(cwd, path);
let virtual_module_path = path
.parent()
.unwrap_or(Path::new(""))
.join(format!("{}{}.js", entry_name, VIRTUAL_ENTRY_SUFFIX));
Ok(HtmlEntrypoint {
path: path.to_path_buf(),
scripts,
temp_module,
contents,
entry_name,
virtual_module_path,
})
}
pub fn load_html_entrypoint(
cwd: &Path,
path: &Path,
) -> anyhow::Result<HtmlEntrypoint> {
let contents = std::fs::read_to_string(path)?;
parse_html_entrypoint(cwd, path, contents)
}
#[derive(Debug, Clone)]
pub struct ParsedOutput {
path: PathBuf,
index: usize,
hash: String,
}
#[derive(Debug)]
pub struct HtmlOutputFiles<'a, 'f> {
output_files: &'f mut Vec<OutputFile<'a>>,
index: HashMap<String, ParsedOutput>,
}
impl<'a, 'f> HtmlOutputFiles<'a, 'f> {
pub fn new(output_files: &'f mut Vec<OutputFile<'a>>) -> Self {
let re =
lazy_regex::regex!(r"(^.+\.deno-bundle-html.entry)-([^.]+)(\..+)$");
let mut index = std::collections::HashMap::new();
for (i, f) in output_files.iter().enumerate() {
if let Some(name) = f.path.file_name().map(|s| s.to_string_lossy()) {
let Some(captures) = re.captures(&name) else {
continue;
};
let mut entry_name = captures.get(1).unwrap().as_str().to_string();
let ext = captures.get(3).unwrap().as_str();
entry_name.push_str(ext);
index.insert(
entry_name,
ParsedOutput {
path: f.path.clone(),
index: i,
hash: captures.get(2).unwrap().as_str().to_string(),
},
);
}
}
Self {
output_files,
index,
}
}
pub fn get_and_update_path(
&mut self,
name: &str,
f: impl FnOnce(PathBuf, &ParsedOutput) -> PathBuf,
) -> Option<PathBuf> {
let parsed_output = self.index.get_mut(name)?;
let new_path = f(parsed_output.path.clone(), parsed_output);
parsed_output.path = new_path.clone();
self.output_files[parsed_output.index].path = new_path.clone();
Some(new_path)
}
}
impl HtmlEntrypoint {
fn original_entry_name(&self) -> String {
self.path.file_stem().unwrap().to_string_lossy().to_string()
}
pub fn patch_html_with_response<'a>(
self,
_cwd: &Path,
outdir: &Path,
html_output_files: &mut HtmlOutputFiles<'a, '_>,
) -> anyhow::Result<()> {
let original_entry_name = self.original_entry_name();
if self.scripts.is_empty() {
let html_out_path =
// TODO(nathanwhit): not really correct
{ outdir.join(format!("{}.html", &original_entry_name)) };
html_output_files.output_files.push(OutputFile {
path: html_out_path,
contents: Cow::Owned(self.contents.into_bytes()),
hash: None,
});
return Ok(());
}
let entry_name = format!("{}{}", self.entry_name, VIRTUAL_ENTRY_SUFFIX);
let js_entry_name = format!("{}.js", entry_name);
let mut js_out_no_hash = None;
let js_out = html_output_files
.get_and_update_path(&js_entry_name, |p, f| {
let p = p.to_string_lossy();
js_out_no_hash = Some(
p.replace(entry_name.as_str(), &original_entry_name)
.replace(&format!("-{}", f.hash), "")
.into(),
);
p.replace(entry_name.as_str(), &original_entry_name).into()
})
.ok_or_else(|| {
anyhow::anyhow!(
"failed to locate output for HTML entry '{}'; {js_entry_name}",
self.entry_name
)
})?;
let html_out_path = js_out_no_hash
.unwrap_or_else(|| js_out.clone())
.with_extension("html");
let css_entry_name = format!("{}.css", entry_name);
let css_out =
html_output_files.get_and_update_path(&css_entry_name, |p, _| {
p.to_string_lossy()
.replace(entry_name.as_str(), &original_entry_name)
.into()
});
let script_src = {
let base = html_out_path.parent().unwrap_or(outdir);
let mut rel = pathdiff::diff_paths(&js_out, base)
.unwrap_or_else(|| js_out.clone())
.to_string_lossy()
.into_owned();
if std::path::MAIN_SEPARATOR != '/' {
rel = rel.replace('\\', "/");
}
rel
};
let any_async = self.scripts.iter().any(|s| s.is_async);
let any_module = self.scripts.iter().any(|s| s.is_module);
let to_inject = Script {
src: Some(
if !script_src.starts_with(".") && !script_src.starts_with("/") {
format!("./{}", script_src)
} else {
script_src
},
),
is_async: any_async,
is_module: any_module,
resolved_path: None,
};
let css_href = css_out.as_ref().map(|p| {
let base = html_out_path.parent().unwrap_or(outdir);
let mut rel = pathdiff::diff_paths(p, base)
.unwrap_or_else(|| p.clone())
.to_string_lossy()
.into_owned();
if std::path::MAIN_SEPARATOR != '/' {
rel = rel.replace('\\', "/");
}
if !rel.starts_with(".") && !rel.starts_with("/") {
rel = format!("./{}", rel);
}
rel
});
let patched = inject_scripts_and_css(
&self.contents,
to_inject,
&self.scripts,
css_href,
)?;
html_output_files.output_files.push(OutputFile {
path: html_out_path,
contents: Cow::Owned(patched.into_bytes()),
hash: None,
});
Ok(())
}
}
fn make_link_str(attrs: &[Attr]) -> String {
StringBuilder::build(|out| {
out.append("<link");
write_attr_list(attrs, out);
out.append(">");
})
.unwrap()
}
fn stylesheet_str(path: &str) -> String {
let attrs = &[
Attr::new("rel", Some("stylesheet".into())),
Attr::new("crossorigin", None),
Attr::new("href", Some(Cow::Borrowed(path))),
];
make_link_str(attrs)
}
fn inject_scripts_and_css(
input: &str,
to_inject: Script,
to_remove: &[Script],
css_to_inject_path: Option<String>,
) -> anyhow::Result<String> {
let did_inject = Cell::new(false);
let rewritten = lol_html::rewrite_str(
input,
lol_html::Settings {
element_content_handlers: vec![
element!("head", |el| {
let already_done = did_inject.replace(true);
if already_done {
return Ok(());
}
el.append(&to_inject.to_element_string(), LolContentType::Html);
if let Some(css_to_inject_path) = &css_to_inject_path {
let link = stylesheet_str(css_to_inject_path);
el.append(&link, LolContentType::Html);
}
Ok(())
}),
element!("script[src]", |el| {
let src = el.get_attribute("src").unwrap();
if to_remove
.iter()
.any(|script| script.src.as_deref() == Some(src.as_str()))
{
el.remove();
}
Ok(())
}),
],
document_content_handlers: vec![lol_html::end!(|end| {
if !did_inject.replace(true) {
let script = to_inject.to_element_string();
let link = css_to_inject_path
.as_ref()
.map(|p| stylesheet_str(p))
.unwrap_or_default();
end.append(
&format!("<head>{script}{link}</head>"),
LolContentType::Html,
);
}
Ok(())
})],
..lol_html::Settings::new()
},
)?;
Ok(rewritten)
}

View file

@ -2,6 +2,7 @@
mod esbuild; mod esbuild;
mod externals; mod externals;
mod html;
mod provider; mod provider;
mod transform; mod transform;
@ -45,7 +46,6 @@ use deno_resolver::loader::RequestedModuleType;
use deno_resolver::npm::managed::ResolvePkgFolderFromDenoModuleError; use deno_resolver::npm::managed::ResolvePkgFolderFromDenoModuleError;
use deno_runtime::deno_permissions::PermissionsContainer; use deno_runtime::deno_permissions::PermissionsContainer;
use deno_semver::npm::NpmPackageReqReference; use deno_semver::npm::NpmPackageReqReference;
use esbuild_client::EsbuildFlags;
use esbuild_client::EsbuildFlagsBuilder; use esbuild_client::EsbuildFlagsBuilder;
use esbuild_client::EsbuildService; use esbuild_client::EsbuildService;
use esbuild_client::protocol; use esbuild_client::protocol;
@ -80,6 +80,105 @@ use crate::util::file_watcher::WatcherRestartMode;
static DISABLE_HACK: LazyLock<bool> = static DISABLE_HACK: LazyLock<bool> =
LazyLock::new(|| std::env::var("NO_DENO_BUNDLE_HACK").is_err()); LazyLock::new(|| std::env::var("NO_DENO_BUNDLE_HACK").is_err());
pub async fn prepare_inputs(
resolver: &CliResolver,
sys: CliSys,
npm_resolver: &CliNpmResolver,
node_resolver: &CliNodeResolver,
init_cwd: &Path,
bundle_flags: &BundleFlags,
plugin_handler: &mut DenoPluginHandler,
) -> Result<BundlerInput, AnyError> {
let resolved_entrypoints =
resolve_entrypoints(resolver, init_cwd, &bundle_flags.entrypoints)?;
// Partition into HTML and non-HTML entrypoints
let mut html_paths = Vec::new();
let mut script_entry_urls = Vec::new();
for url in &resolved_entrypoints {
if url.as_str().to_lowercase().ends_with(".html") {
html_paths.push(url.to_file_path().unwrap());
} else {
script_entry_urls.push(url.clone());
}
}
if html_paths.is_empty() {
let _ = plugin_handler
.prepare_module_load(&resolved_entrypoints)
.await;
let roots =
resolve_roots(resolved_entrypoints, sys, npm_resolver, node_resolver);
let _ = plugin_handler.prepare_module_load(&roots).await;
Ok(BundlerInput::Entrypoints(
roots.into_iter().map(|e| ("".into(), e.into())).collect(),
))
} else {
// require an outdir when any HTML is present
if bundle_flags.output_dir.is_none() {
return Err(deno_core::anyhow::anyhow!(
"--outdir is required when bundling HTML entrypoints",
));
}
if bundle_flags.output_path.is_some() {
return Err(deno_core::anyhow::anyhow!(
"--output is not supported with HTML entrypoints; use --outdir",
));
}
// Prepare HTML pages and temp entry modules
let mut html_pages = Vec::new();
let mut to_cache_urls = Vec::new();
let mut entries: Vec<(String, String)> = Vec::new();
let mut virtual_modules = VirtualModules::new();
for html_path in &html_paths {
let entry = html::load_html_entrypoint(init_cwd, html_path)?;
let virtual_module_path =
deno_path_util::url_from_file_path(&entry.virtual_module_path)?;
let virtual_module_path = virtual_module_path.to_string();
virtual_modules.insert(
virtual_module_path.clone(),
VirtualModule::new(
entry.temp_module.as_bytes().to_vec(),
esbuild_client::BuiltinLoader::Js,
),
);
for script in &entry.scripts {
if let Some(path) = &script.resolved_path {
let url = deno_path_util::url_from_file_path(path)?;
to_cache_urls.push(url);
}
}
entries.push(("".into(), virtual_module_path));
html_pages.push(entry);
}
plugin_handler.virtual_modules = Some(Arc::new(virtual_modules));
// Prepare non-HTML entries too
let _ = plugin_handler.prepare_module_load(&script_entry_urls).await;
let roots =
resolve_roots(script_entry_urls, sys, npm_resolver, node_resolver);
let _ = plugin_handler.prepare_module_load(&roots).await;
for url in roots {
entries.push(("".into(), url.into()));
}
// Pre-cache modules referenced by HTML pages
let _ = plugin_handler.prepare_module_load(&to_cache_urls).await;
Ok(BundlerInput::EntrypointsWithHtml {
entries,
html_pages,
})
}
}
pub async fn bundle_init( pub async fn bundle_init(
mut flags: Arc<Flags>, mut flags: Arc<Flags>,
bundle_flags: &BundleFlags, bundle_flags: &BundleFlags,
@ -106,7 +205,7 @@ pub async fn bundle_init(
let (on_end_tx, on_end_rx) = tokio::sync::mpsc::channel(10); let (on_end_tx, on_end_rx) = tokio::sync::mpsc::channel(10);
#[allow(clippy::arc_with_non_send_sync)] #[allow(clippy::arc_with_non_send_sync)]
let plugin_handler = Arc::new(DenoPluginHandler { let mut plugin_handler = Arc::new(DenoPluginHandler {
file_fetcher: factory.file_fetcher()?.clone(), file_fetcher: factory.file_fetcher()?.clone(),
resolver: resolver.clone(), resolver: resolver.clone(),
module_load_preparer, module_load_preparer,
@ -123,17 +222,19 @@ pub async fn bundle_init(
cjs_tracker: factory.cjs_tracker()?.clone(), cjs_tracker: factory.cjs_tracker()?.clone(),
emitter: factory.emitter()?.clone(), emitter: factory.emitter()?.clone(),
deferred_resolve_errors: Default::default(), deferred_resolve_errors: Default::default(),
virtual_modules: None,
}); });
let resolved_entrypoints = let input = prepare_inputs(
resolve_entrypoints(&resolver, &init_cwd, &bundle_flags.entrypoints)?; &resolver,
let _ = plugin_handler sys,
.prepare_module_load(&resolved_entrypoints) npm_resolver,
.await; node_resolver,
&init_cwd,
let roots = bundle_flags,
resolve_roots(resolved_entrypoints, sys, npm_resolver, node_resolver); Arc::get_mut(&mut plugin_handler).unwrap(),
let _ = plugin_handler.prepare_module_load(&roots).await; )
.await?;
let esbuild = EsbuildService::new( let esbuild = EsbuildService::new(
esbuild_path, esbuild_path,
@ -149,9 +250,10 @@ pub async fn bundle_init(
let res = esbuild.wait_for_exit().await; let res = esbuild.wait_for_exit().await;
log::warn!("esbuild exited: {:?}", res); log::warn!("esbuild exited: {:?}", res);
}); });
let esbuild_flags = configure_esbuild_flags(
let esbuild_flags = configure_esbuild_flags(bundle_flags); bundle_flags,
let entries = roots.into_iter().map(|e| ("".into(), e.into())).collect(); matches!(input, BundlerInput::EntrypointsWithHtml { .. }),
);
let bundler = EsbuildBundler::new( let bundler = EsbuildBundler::new(
client, client,
plugin_handler.clone(), plugin_handler.clone(),
@ -162,7 +264,7 @@ pub async fn bundle_init(
on_end_rx, on_end_rx,
init_cwd.clone(), init_cwd.clone(),
esbuild_flags, esbuild_flags,
entries, input.clone(),
); );
Ok(bundler) Ok(bundler)
@ -199,6 +301,7 @@ pub async fn bundle(
bundler, bundler,
bundle_flags.minify, bundle_flags.minify,
bundle_flags.platform, bundle_flags.platform,
bundle_flags.output_dir.as_ref().map(Path::new),
) )
.await; .await;
} }
@ -216,6 +319,8 @@ pub async fn bundle(
&init_cwd, &init_cwd,
should_replace_require_shim(bundle_flags.platform), should_replace_require_shim(bundle_flags.platform),
bundle_flags.minify, bundle_flags.minify,
bundler.input.clone(),
bundle_flags.output_dir.as_ref().map(Path::new),
)?; )?;
if bundle_flags.output_dir.is_some() || bundle_flags.output_path.is_some() { if bundle_flags.output_dir.is_some() || bundle_flags.output_path.is_some() {
@ -245,16 +350,41 @@ async fn bundle_watch(
bundler: EsbuildBundler, bundler: EsbuildBundler,
minified: bool, minified: bool,
platform: BundlePlatform, platform: BundlePlatform,
output_dir: Option<&Path>,
) -> Result<(), AnyError> { ) -> Result<(), AnyError> {
let initial_roots = bundler let (initial_roots, always_watch) = match &bundler.input {
.roots BundlerInput::Entrypoints(entries) => (
entries
.iter()
.filter_map(|(_, root)| {
let url = Url::parse(root).ok()?;
deno_path_util::url_to_file_path(&url).ok()
})
.collect::<Vec<_>>(),
vec![],
),
BundlerInput::EntrypointsWithHtml {
entries,
html_pages,
} => {
let mut roots = entries
.iter() .iter()
.filter_map(|(_, root)| { .filter_map(|(_, root)| {
let url = Url::parse(root).ok()?; let url = Url::parse(root).ok()?;
deno_path_util::url_to_file_path(&url).ok() deno_path_util::url_to_file_path(&url).ok()
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let always = html_pages
.iter()
.map(|p| p.path.clone())
.collect::<Vec<_>>();
roots.extend(always.iter().cloned());
(roots, always)
}
};
let always_watch = Rc::new(always_watch);
let current_roots = Rc::new(RefCell::new(initial_roots.clone())); let current_roots = Rc::new(RefCell::new(initial_roots.clone()));
let input = bundler.input.clone();
let bundler = Rc::new(tokio::sync::Mutex::new(bundler)); let bundler = Rc::new(tokio::sync::Mutex::new(bundler));
let mut print_config = let mut print_config =
crate::util::file_watcher::PrintConfig::new_with_banner( crate::util::file_watcher::PrintConfig::new_with_banner(
@ -269,6 +399,8 @@ async fn bundle_watch(
watcher_communicator.show_path_changed(changed_paths.clone()); watcher_communicator.show_path_changed(changed_paths.clone());
let bundler = Rc::clone(&bundler); let bundler = Rc::clone(&bundler);
let current_roots = current_roots.clone(); let current_roots = current_roots.clone();
let input = input.clone();
let always_watch = always_watch.clone();
Ok(async move { Ok(async move {
let mut bundler = bundler.lock().await; let mut bundler = bundler.lock().await;
let start = std::time::Instant::now(); let start = std::time::Instant::now();
@ -291,10 +423,13 @@ async fn bundle_watch(
&bundler.cwd, &bundler.cwd,
should_replace_require_shim(platform), should_replace_require_shim(platform),
minified, minified,
input,
output_dir,
)?; )?;
print_finished_message(&metafile, &output_infos, start.elapsed())?; print_finished_message(&metafile, &output_infos, start.elapsed())?;
let new_watched = get_input_paths_for_watch(&response); let mut new_watched = get_input_paths_for_watch(&response);
new_watched.extend(always_watch.iter().cloned());
*current_roots.borrow_mut() = new_watched.clone(); *current_roots.borrow_mut() = new_watched.clone();
let _ = watcher_communicator.watch_paths(new_watched); let _ = watcher_communicator.watch_paths(new_watched);
} else { } else {
@ -339,6 +474,17 @@ pub enum BundlingMode {
Watch, Watch,
} }
#[derive(Debug, Clone)]
pub enum BundlerInput {
Entrypoints(Vec<(String, String)>),
EntrypointsWithHtml {
entries: Vec<(String, String)>,
html_pages: Vec<html::HtmlEntrypoint>,
},
}
pub type EsbuildFlags = Vec<String>;
pub struct EsbuildBundler { pub struct EsbuildBundler {
client: esbuild_client::ProtocolClient, client: esbuild_client::ProtocolClient,
plugin_handler: Arc<DenoPluginHandler>, plugin_handler: Arc<DenoPluginHandler>,
@ -346,7 +492,7 @@ pub struct EsbuildBundler {
mode: BundlingMode, mode: BundlingMode,
cwd: PathBuf, cwd: PathBuf,
flags: EsbuildFlags, flags: EsbuildFlags,
roots: Vec<(String, String)>, input: BundlerInput,
} }
impl EsbuildBundler { impl EsbuildBundler {
@ -357,7 +503,7 @@ impl EsbuildBundler {
on_end_rx: tokio::sync::mpsc::Receiver<esbuild_client::OnEndArgs>, on_end_rx: tokio::sync::mpsc::Receiver<esbuild_client::OnEndArgs>,
cwd: PathBuf, cwd: PathBuf,
flags: EsbuildFlags, flags: EsbuildFlags,
roots: Vec<(String, String)>, input: BundlerInput,
) -> EsbuildBundler { ) -> EsbuildBundler {
EsbuildBundler { EsbuildBundler {
client, client,
@ -366,7 +512,7 @@ impl EsbuildBundler {
mode, mode,
cwd, cwd,
flags, flags,
roots, input,
} }
} }
@ -376,10 +522,14 @@ impl EsbuildBundler {
// doesn't actually do anything, it's just registering the args/flags // doesn't actually do anything, it's just registering the args/flags
// we're going to use for all of the rebuilds. // we're going to use for all of the rebuilds.
fn make_build_request(&self) -> protocol::BuildRequest { fn make_build_request(&self) -> protocol::BuildRequest {
let entries = match &self.input {
BundlerInput::Entrypoints(entries) => entries.clone(),
BundlerInput::EntrypointsWithHtml { entries, .. } => entries.clone(),
};
protocol::BuildRequest { protocol::BuildRequest {
entries: self.roots.clone(), entries,
key: 0, key: 0,
flags: self.flags.to_flags(), flags: self.flags.clone(),
write: false, write: false,
stdin_contents: None.into(), stdin_contents: None.into(),
stdin_resolve_dir: None.into(), stdin_resolve_dir: None.into(),
@ -406,7 +556,7 @@ impl EsbuildBundler {
} }
async fn build(&self) -> Result<BuildResponse, AnyError> { async fn build(&self) -> Result<BuildResponse, AnyError> {
let response = self let response: BuildResponse = self
.client .client
.send_build_request(self.make_build_request()) .send_build_request(self.make_build_request())
.await .await
@ -589,6 +739,41 @@ fn requested_type_from_map(
} }
} }
pub struct VirtualModule {
contents: Vec<u8>,
loader: esbuild_client::BuiltinLoader,
}
impl VirtualModule {
pub fn new(contents: Vec<u8>, loader: esbuild_client::BuiltinLoader) -> Self {
Self { contents, loader }
}
}
pub struct VirtualModules {
modules: IndexMap<String, VirtualModule>,
}
impl VirtualModules {
pub fn new() -> Self {
Self {
modules: IndexMap::new(),
}
}
pub fn insert(&mut self, path: String, contents: VirtualModule) {
self.modules.insert(path, contents);
}
pub fn get(&self, path: &str) -> Option<&VirtualModule> {
self.modules.get(path)
}
pub fn contains(&self, path: &str) -> bool {
self.modules.contains_key(path)
}
}
pub struct DeferredResolveError { pub struct DeferredResolveError {
path: String, path: String,
error: ResolveWithGraphError, error: ResolveWithGraphError,
@ -604,7 +789,7 @@ pub struct DenoPluginHandler {
externals_matcher: Option<ExternalsMatcher>, externals_matcher: Option<ExternalsMatcher>,
on_end_tx: tokio::sync::mpsc::Sender<esbuild_client::OnEndArgs>, on_end_tx: tokio::sync::mpsc::Sender<esbuild_client::OnEndArgs>,
deferred_resolve_errors: Arc<Mutex<Vec<DeferredResolveError>>>, deferred_resolve_errors: Arc<Mutex<Vec<DeferredResolveError>>>,
virtual_modules: Option<Arc<VirtualModules>>,
parsed_source_cache: Arc<ParsedSourceCache>, parsed_source_cache: Arc<ParsedSourceCache>,
cjs_tracker: Arc<CliCjsTracker>, cjs_tracker: Arc<CliCjsTracker>,
emitter: Arc<CliEmitter>, emitter: Arc<CliEmitter>,
@ -673,6 +858,18 @@ impl esbuild_client::PluginHandler for DenoPluginHandler {
args: esbuild_client::OnResolveArgs, args: esbuild_client::OnResolveArgs,
) -> Result<Option<esbuild_client::OnResolveResult>, AnyError> { ) -> Result<Option<esbuild_client::OnResolveResult>, AnyError> {
log::debug!("{}: {args:?}", deno_terminal::colors::cyan("on_resolve")); log::debug!("{}: {args:?}", deno_terminal::colors::cyan("on_resolve"));
if let Some(virtual_modules) = &self.virtual_modules
&& virtual_modules.contains(&args.path)
{
return Ok(Some(esbuild_client::OnResolveResult {
path: Some(args.path),
plugin_name: Some("deno".to_string()),
namespace: Some("deno".to_string()),
..Default::default()
}));
}
if let Some(matcher) = &self.externals_matcher if let Some(matcher) = &self.externals_matcher
&& matcher.is_pre_resolve_match(&args.path) && matcher.is_pre_resolve_match(&args.path)
{ {
@ -743,6 +940,15 @@ impl esbuild_client::PluginHandler for DenoPluginHandler {
args: esbuild_client::OnLoadArgs, args: esbuild_client::OnLoadArgs,
) -> Result<Option<esbuild_client::OnLoadResult>, AnyError> { ) -> Result<Option<esbuild_client::OnLoadResult>, AnyError> {
log::debug!("{}: {args:?}", deno_terminal::colors::cyan("on_load")); log::debug!("{}: {args:?}", deno_terminal::colors::cyan("on_load"));
if let Some(virtual_modules) = &self.virtual_modules
&& let Some(module) = virtual_modules.get(&args.path)
{
return Ok(Some(esbuild_client::OnLoadResult {
contents: Some(module.contents.clone()),
loader: Some(module.loader),
..Default::default()
}));
}
let result = self let result = self
.bundle_load(&args.path, &requested_type_from_map(&args.with)) .bundle_load(&args.path, &requested_type_from_map(&args.with))
.await; .await;
@ -1497,14 +1703,17 @@ async fn ensure_esbuild_downloaded(
Ok(esbuild_path) Ok(esbuild_path)
} }
fn configure_esbuild_flags(bundle_flags: &BundleFlags) -> EsbuildFlags { fn configure_esbuild_flags(
bundle_flags: &BundleFlags,
is_html: bool,
) -> Vec<String> {
let mut builder = EsbuildFlagsBuilder::default(); let mut builder = EsbuildFlagsBuilder::default();
builder builder
.bundle(bundle_flags.inline_imports) .bundle(bundle_flags.inline_imports)
.minify(bundle_flags.minify) .minify(bundle_flags.minify)
.splitting(bundle_flags.code_splitting) .splitting(bundle_flags.code_splitting)
.external(bundle_flags.external.clone()) .externals(bundle_flags.external.clone())
.tree_shaking(true) .tree_shaking(true)
.format(match bundle_flags.format { .format(match bundle_flags.format {
BundleFormat::Esm => esbuild_client::Format::Esm, BundleFormat::Esm => esbuild_client::Format::Esm,
@ -1531,6 +1740,14 @@ fn configure_esbuild_flags(bundle_flags: &BundleFlags) -> EsbuildFlags {
} }
builder.metafile(true); builder.metafile(true);
if is_html {
builder.platform(esbuild_client::Platform::Browser);
builder.splitting(true);
builder.entry_names("[dir]/[name]-[hash]");
builder.chunk_names("[dir]/[name]-[hash]");
builder.asset_names("[dir]/[name]-[hash]");
builder.metafile(true);
}
match bundle_flags.platform { match bundle_flags.platform {
deno_bundle_runtime::BundlePlatform::Browser => { deno_bundle_runtime::BundlePlatform::Browser => {
builder.platform(esbuild_client::Platform::Browser); builder.platform(esbuild_client::Platform::Browser);
@ -1538,7 +1755,7 @@ fn configure_esbuild_flags(bundle_flags: &BundleFlags) -> EsbuildFlags {
deno_bundle_runtime::BundlePlatform::Deno => {} deno_bundle_runtime::BundlePlatform::Deno => {}
} }
builder.build().unwrap() builder.build()
} }
// extract the path from a message like "Could not resolve "path/to/file.ts"" // extract the path from a message like "Could not resolve "path/to/file.ts""
@ -1588,17 +1805,6 @@ fn handle_esbuild_errors_and_warnings(
} }
} }
fn is_js(path: &Path) -> bool {
if let Some(ext) = path.extension() {
matches!(
ext.to_string_lossy().as_ref(),
"js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" | "mts" | "cts" | "dts"
)
} else {
false
}
}
pub struct OutputFileInfo { pub struct OutputFileInfo {
relative_path: PathBuf, relative_path: PathBuf,
size: usize, size: usize,
@ -1610,19 +1816,30 @@ pub struct ProcessedContents {
is_js: bool, is_js: bool,
} }
fn is_js(path: &Path) -> bool {
if let Some(ext) = path.extension() {
matches!(
ext.to_string_lossy().as_ref(),
"js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" | "mts" | "cts" | "dts"
)
} else {
false
}
}
pub fn maybe_process_contents( pub fn maybe_process_contents(
file: &esbuild_client::protocol::BuildOutputFile, file: &OutputFile<'_>,
should_replace_require_shim: bool, should_replace_require_shim: bool,
minified: bool, minified: bool,
) -> Result<ProcessedContents, AnyError> { ) -> Result<ProcessedContents, AnyError> {
let path = Path::new(&file.path); let path = &file.path;
let is_js = is_js(path) || file.path.ends_with("<stdout>"); let is_js = is_js(path) || path.ends_with("<stdout>");
if is_js { if is_js {
let string = String::from_utf8(file.contents.clone())?; let string = str::from_utf8(&file.contents)?;
let string = if should_replace_require_shim { let string = if should_replace_require_shim {
replace_require_shim(&string, minified) replace_require_shim(string, minified)
} else { } else {
string string.to_string()
}; };
Ok(ProcessedContents { Ok(ProcessedContents {
contents: Some(string.into_bytes()), contents: Some(string.into_bytes()),
@ -1635,18 +1852,101 @@ pub fn maybe_process_contents(
}) })
} }
} }
pub struct OutputFile<'a> {
pub path: PathBuf,
pub contents: Cow<'a, [u8]>,
pub hash: Option<String>,
}
impl<'a> std::fmt::Debug for OutputFile<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OutputFile")
.field("path", &self.path)
.field("hash", &self.hash)
.finish()
}
}
impl<'a> From<&'a esbuild_client::protocol::BuildOutputFile>
for OutputFile<'a>
{
fn from(file: &'a esbuild_client::protocol::BuildOutputFile) -> Self {
OutputFile {
path: PathBuf::from(&file.path),
contents: Cow::Borrowed(&file.contents),
hash: Some(file.hash.clone()),
}
}
}
impl<'a> From<esbuild_client::protocol::BuildOutputFile> for OutputFile<'a> {
fn from(file: esbuild_client::protocol::BuildOutputFile) -> Self {
OutputFile {
path: PathBuf::from(&file.path),
contents: Cow::Owned(file.contents),
hash: Some(file.hash),
}
}
}
pub fn collect_output_files<'a>(
response_output_files: Option<&'a [protocol::BuildOutputFile]>,
cwd: &Path,
input: BundlerInput,
outdir: Option<&Path>,
) -> Result<Vec<OutputFile<'a>>, AnyError> {
let outdir = if let Some(outdir) = outdir {
if outdir.is_absolute() {
Some(outdir.to_path_buf())
} else {
Some(cwd.join(outdir))
}
} else {
None
};
let mut output_files: Vec<OutputFile> = response_output_files
.map(|fs| {
fs.iter()
.map(|f| OutputFile {
path: PathBuf::from(&f.path),
contents: Cow::Borrowed(&f.contents),
hash: Some(f.hash.clone()),
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if let BundlerInput::EntrypointsWithHtml {
entries: _,
html_pages,
} = input
{
let outdir = outdir.ok_or_else(|| {
deno_core::anyhow::anyhow!(
"--outdir is required when bundling HTML entrypoints",
)
})?;
let mut html_output_files = html::HtmlOutputFiles::new(&mut output_files);
for page in html_pages {
page.patch_html_with_response(cwd, &outdir, &mut html_output_files)?;
}
}
Ok(output_files)
}
pub fn process_result( pub fn process_result(
response: &BuildResponse, response: &BuildResponse,
cwd: &Path, cwd: &Path,
should_replace_require_shim: bool, should_replace_require_shim: bool,
minified: bool, minified: bool,
input: BundlerInput,
outdir: Option<&Path>,
) -> Result<Vec<OutputFileInfo>, AnyError> { ) -> Result<Vec<OutputFileInfo>, AnyError> {
let output_files =
collect_output_files(response.output_files.as_deref(), cwd, input, outdir)?;
let mut exists_cache = std::collections::HashSet::new(); let mut exists_cache = std::collections::HashSet::new();
let output_files = response
.output_files
.as_ref()
.map(Cow::Borrowed)
.unwrap_or_default();
let mut output_infos = Vec::new(); let mut output_infos = Vec::new();
for file in output_files.iter() { for file in output_files.iter() {
let processed_contents = let processed_contents =
@ -1655,13 +1955,13 @@ pub fn process_result(
let relative_path = let relative_path =
pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf()); pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf());
let is_js = processed_contents.is_js; let is_js = processed_contents.is_js;
let bytes = processed_contents let bytes: Cow<'_, [u8]> = processed_contents
.contents .contents
.map(Cow::Owned) .map(Cow::Owned)
.unwrap_or_else(|| Cow::Borrowed(&file.contents)); .unwrap_or_else(|| Cow::Borrowed(file.contents.as_ref()));
if file.path.ends_with("<stdout>") { if file.path.ends_with("<stdout>") {
crate::display::write_to_stdout_ignore_sigpipe(bytes.as_slice())?; crate::display::write_to_stdout_ignore_sigpipe(bytes.as_ref())?;
continue; continue;
} }

View file

@ -1,5 +1,6 @@
// Copyright 2018-2025 the Deno authors. MIT license. // Copyright 2018-2025 the Deno authors. MIT license.
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use deno_bundle_runtime as rt_bundle; use deno_bundle_runtime as rt_bundle;
@ -90,23 +91,46 @@ pub fn convert_build_response(
} }
} }
fn hash_contents(contents: &[u8]) -> String {
use base64::prelude::*;
let hash = twox_hash::XxHash64::oneshot(0, contents);
let bytes = hash.to_le_bytes();
base64::engine::general_purpose::STANDARD_NO_PAD.encode(bytes)
}
fn process_output_files( fn process_output_files(
bundle_flags: &crate::args::BundleFlags, bundle_flags: &crate::args::BundleFlags,
response: &mut esbuild_client::protocol::BuildResponse, response: &mut esbuild_client::protocol::BuildResponse,
cwd: &Path,
input: super::BundlerInput,
) -> Result<(), AnyError> { ) -> Result<(), AnyError> {
if let Some(files) = &mut response.output_files { if let Some(files) = std::mem::take(&mut response.output_files) {
for file in files { let output_files = super::collect_output_files(
Some(&*files),
cwd,
input,
bundle_flags.output_dir.as_ref().map(Path::new),
)?;
let mut new_files = Vec::new();
for output_file in output_files {
let processed_contents = crate::tools::bundle::maybe_process_contents( let processed_contents = crate::tools::bundle::maybe_process_contents(
file, &output_file,
crate::tools::bundle::should_replace_require_shim( crate::tools::bundle::should_replace_require_shim(
bundle_flags.platform, bundle_flags.platform,
), ),
bundle_flags.minify, bundle_flags.minify,
)?; )?;
if let Some(contents) = processed_contents.contents { let contents = processed_contents
file.contents = contents; .contents
} .unwrap_or_else(|| output_file.contents.into_owned());
new_files.push(esbuild_client::protocol::BuildOutputFile {
path: output_file.path.to_string_lossy().into_owned(),
hash: hash_contents(&contents),
contents,
});
} }
response.output_files = Some(new_files);
} }
Ok(()) Ok(())
} }
@ -151,10 +175,17 @@ impl BundleProvider for CliBundleProvider {
&bundler.cwd, &bundler.cwd,
true, true,
bundle_flags.minify, bundle_flags.minify,
bundler.input,
bundle_flags.output_dir.as_ref().map(Path::new),
)?; )?;
result.output_files = None; result.output_files = None;
} else { } else {
process_output_files(&bundle_flags, &mut result)?; process_output_files(
&bundle_flags,
&mut result,
&bundler.cwd,
bundler.input,
)?;
} }
log::trace!("convert_build_response"); log::trace!("convert_build_response");
let result = convert_build_response(result); let result = convert_build_response(result);

View file

@ -0,0 +1,65 @@
{
"tempDir": true,
"tests": {
"basic": {
"steps": [
{
"args": "bundle --outdir ./dist index.html",
"output": "basic-bundle.out"
},
{
"args": "run -A basic-bundle.asserts.ts",
"output": ""
}
]
},
"multiple_scripts": {
"steps": [
{
"args": "bundle --outdir ./dist multiple-scripts.html",
"output": "multiple-scripts-bundle.out"
},
{
"args": "run -A multiple-scripts-bundle.asserts.ts",
"output": ""
}
]
},
"multiple_html": {
"steps": [
{
"args": "bundle --outdir ./dist index.html multiple-html.html",
"output": "multiple-html-bundle.out"
},
{
"args": "run -A multiple-html-bundle.asserts.ts",
"output": ""
}
]
},
"imports_css": {
"steps": [
{
"args": "bundle --outdir ./dist imports-css.html",
"output": "imports-css-bundle.out"
},
{
"args": "run -A imports-css-bundle.asserts.ts",
"output": ""
}
]
},
"same_name_sub_folder": {
"steps": [
{
"args": "bundle --outdir ./dist index.html sub/index.html",
"output": "same-name-sub-folder-bundle.out"
},
{
"args": "run -A same-name-sub-folder-bundle.asserts.ts",
"output": ""
}
]
}
}
}

View file

@ -0,0 +1,3 @@
export const a = 1;
document.body.insertAdjacentHTML("beforeend", "A");
console.log("A", a);

View file

@ -0,0 +1,32 @@
export function assertFileContains(path: string, pattern: string | RegExp) {
const contents = Deno.readTextFileSync(path);
let matcher: (s: string) => boolean;
if (typeof pattern === "string") {
matcher = (s) => s.includes(pattern);
} else {
matcher = (s) => pattern.test(s);
}
if (!matcher(contents)) {
let message = "";
message += "file does not contain the pattern: " + path + "\n";
message += "wanted: " + pattern + "\n";
message += "found: " + contents + "\n";
throw new Error(message);
}
}
export function assertFileDoesNotContain(
path: string,
pattern: string | RegExp,
) {
try {
assertFileContains(path, pattern);
} catch (_e) {
return;
}
let message = "";
message += "file contains the pattern: " + path + "\n";
message += "did not want: " + pattern + "\n";
message += "found: " + Deno.readTextFileSync(path) + "\n";
throw new Error(message);
}

View file

@ -0,0 +1,3 @@
export const b = 2;
document.body.insertAdjacentHTML("beforeend", "B");
console.log("B", b);

View file

@ -0,0 +1,4 @@
import { assertFileContains } from "./assert-helpers.ts";
const re = /src="\.\/index-[^\.]+\.js"/;
assertFileContains("./dist/index.html", re);

View file

@ -0,0 +1,5 @@
[WILDCARD]
Bundled 2 modules in [WILDLINE]
dist[WILDCHAR]index-[WILDLINE].js [WILDLINE]
dist[WILDCHAR]index.html [WILDLINE]

View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"lib": [
"deno.window",
"dom"
]
}
}

View file

@ -0,0 +1,26 @@
const path = Deno.args[0];
const pattern = Deno.args[1];
declare global {
interface RegExpConstructor {
escape(string: string): string;
}
}
const contents = Deno.readTextFileSync(path);
let matcher: RegExp | string = pattern;
if (pattern.startsWith("regex:")) {
matcher = pattern.slice(6);
matcher = new RegExp(matcher);
} else {
matcher = new RegExp(RegExp.escape(pattern));
}
if (matcher.test(contents)) {
console.log("true");
} else {
console.log("false");
console.log("wanted: ", matcher);
console.log("found: ", contents);
}

View file

@ -0,0 +1,35 @@
import { assertFileContains } from "./assert-helpers.ts";
assertFileContains(
"./dist/imports-css.html",
/src="\.\/imports-css-[^\.]+\.js"/,
);
assertFileContains(
"./dist/imports-css.html",
/href="\.\/imports-css-[^\.]+\.css"/,
);
const jsCssFiles: string[] = [];
Deno.readDirSync("./dist").forEach((entry) => {
if (entry.name.endsWith(".js") || entry.name.endsWith(".css")) {
jsCssFiles.push("./dist/" + entry.name);
}
});
if (jsCssFiles.length === 0) {
throw new Error("No .js files found");
}
const importsCssJsFile = jsCssFiles.find((file) =>
file.includes("imports-css") && file.endsWith(".js")
);
const cssFile = jsCssFiles.find((file) =>
file.includes("imports-css") && file.endsWith(".css")
);
if (!importsCssJsFile || !cssFile) {
throw new Error("No imports-css.js or imports-css.css file found");
}
assertFileContains(importsCssJsFile, "<h1>Hello, world!</h1>");
assertFileContains(cssFile, "h1 {\n color: red;\n}");

View file

@ -0,0 +1,8 @@
[WILDCARD]
Bundled 3 modules in [WILDLINE]
[UNORDERED_START]
dist[WILDCHAR]imports-css-[WILDLINE].js [WILDLINE]
dist[WILDCHAR]imports-css-[WILDLINE].css [WILDLINE]
dist[WILDCHAR]imports-css.html [WILDLINE]
[UNORDERED_END]

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Deno bundle HTML</title>
<script src="./imports-css.ts" type="module" ></script>
</head>
<body>
<noscript>This app requires JavaScript to run.</noscript>
</body>
</html>

View file

@ -0,0 +1,3 @@
import "./style.css";
document.body.insertAdjacentHTML("beforeend", "<h1>Hello, world!</h1>");

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Deno bundle HTML</title>
<script src="./index.ts" type="module" ></script>
</head>
<body>
<noscript>This app requires JavaScript to run.</noscript>
</body>
</html>

View file

@ -0,0 +1 @@
document.body.innerHTML = "Hello, world!";

View file

@ -0,0 +1,35 @@
import "./basic-bundle.asserts.ts";
import { assertFileContains } from "./assert-helpers.ts";
assertFileContains("./dist/index.html", /src="\.\/index-[^\.]+\.js"/);
assertFileContains(
"./dist/multiple-html.html",
/src="\.\/multiple-html-[^\.]+\.js"/,
);
const jsFiles: string[] = [];
Deno.readDirSync("./dist").forEach((entry) => {
if (entry.name.endsWith(".js")) {
jsFiles.push("./dist/" + entry.name);
}
});
if (jsFiles.length === 0) {
throw new Error("No .js files found");
}
const indexJsFile = jsFiles.find((file) => file.includes("index"));
const multipleHtmlJsFile = jsFiles.find((file) =>
file.includes("multiple-html")
);
if (!indexJsFile || !multipleHtmlJsFile) {
throw new Error("No index.js or multiple-html.js file found");
}
assertFileContains(indexJsFile, "Hello, world!");
assertFileContains(
multipleHtmlJsFile,
'document.body.insertAdjacentHTML("beforeend", "A");',
);

View file

@ -0,0 +1,7 @@
[WILDCARD]
Bundled 4 modules in [WILDLINE]
dist[WILDCHAR]index-[WILDLINE].js [WILDLINE]
dist[WILDCHAR]multiple-html-[WILDLINE].js [WILDLINE]
dist[WILDCHAR]index.html [WILDLINE]
dist[WILDCHAR]multiple-html.html [WILDLINE]

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Deno bundle HTML</title>
<script src="./a.ts" type="module" ></script>
</head>
<body>
<noscript>This app requires JavaScript to run.</noscript>
</body>
</html>

View file

@ -0,0 +1,25 @@
import { assertFileContains } from "./assert-helpers.ts";
let jsFile: string | undefined;
Deno.readDirSync("./dist").forEach((entry) => {
if (entry.name.endsWith(".js")) {
jsFile = "./dist/" + entry.name;
}
});
if (!jsFile) {
throw new Error("No .js file found");
}
assertFileContains(
"./dist/multiple-scripts.html",
/src="\.\/multiple-scripts-[^\.]+\.js"/,
);
assertFileContains(
jsFile,
'insertAdjacentHTML("beforeend", "A")',
);
assertFileContains(
jsFile,
'insertAdjacentHTML("beforeend", "B")',
);

View file

@ -0,0 +1,5 @@
[WILDCARD]
Bundled 3 modules in [WILDLINE]
dist[WILDCHAR]multiple-scripts-[WILDLINE].js [WILDLINE]
dist[WILDCHAR]multiple-scripts.html [WILDLINE]

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Multi</title>
<script src="./a.ts" type="module"></script>
<script src="./b.ts" type="module"></script>
</head>
<body>
</body>
</html>

View file

@ -0,0 +1,56 @@
function printName(name: string, level = 0) {
console.log("-".repeat(level + 1) + " " + name);
}
function walk(
dir: string,
fn: (
{ name, dir, level, kind }: {
name: string;
dir: string;
level: number;
kind: "file" | "dir" | "symlink";
},
) => void,
{ maxLevel }: { maxLevel: number },
) {
const walkRecursive = (dir: string, level: number) => {
if (maxLevel > 0 && level > maxLevel) {
return;
}
const files = Deno.readDirSync(dir).toArray();
files.sort((a, b) => a.name.localeCompare(b.name));
for (const file of files) {
if (file.isDirectory) {
fn({ name: file.name, dir, level, kind: "dir" });
walkRecursive(dir + "/" + file.name, level + 1);
} else if (file.isFile) {
fn({ name: file.name, dir, level, kind: "file" });
} else {
fn({ name: file.name, dir, level, kind: "symlink" });
}
}
};
walkRecursive(dir, 0);
}
function printDir(dir: string, maxLevel = 0) {
walk(dir, ({ name, level, kind }) => {
printName(name + (kind === "symlink" ? " (symlink)" : ""), level);
}, { maxLevel });
}
let maxLevel = 0;
for (let i = 0; i < Deno.args.length; i++) {
const arg = Deno.args[i];
if (arg.startsWith("--max-level=")) {
maxLevel = parseInt(arg.split("=")[1]) - 1;
maxLevel = Math.max(0, maxLevel);
} else {
console.log(arg);
printDir(arg, maxLevel);
if (i < Deno.args.length - 1) {
console.log("");
maxLevel = 0;
}
}
}

View file

@ -0,0 +1,38 @@
import { assertFileContains } from "./assert-helpers.ts";
assertFileContains("./dist/index.html", /src="\.\/index-[^\.]+\.js"/);
assertFileContains("./dist/sub/index.html", /src="\.\/index-[^\.]+\.js"/);
function walk(dir: string, fn: (entry: string) => void) {
Deno.readDirSync(dir).forEach((entry) => {
if (entry.isDirectory) {
walk(dir + "/" + entry.name, fn);
} else {
fn(dir + "/" + entry.name);
}
});
}
const jsFiles: string[] = [];
walk("./dist", (entry) => {
if (entry.endsWith(".js")) {
jsFiles.push(entry);
}
});
if (jsFiles.length === 0) {
throw new Error("No .js files found");
}
const subJsFile = jsFiles.find((file) => file.includes("sub"));
const indexJsFile = jsFiles.find((file) =>
file.includes("index") && !file.includes("sub")
);
if (!indexJsFile || !subJsFile) {
throw new Error("No index.js or sub/index.js file found");
}
assertFileContains(indexJsFile, "Hello, world!");
assertFileContains(subJsFile, "Hello, world from sub!");

View file

@ -0,0 +1,9 @@
[WILDCARD]
Bundled 4 modules in [WILDLINE]
[UNORDERED_START]
dist[WILDCHAR]index-[WILDLINE].js [WILDLINE]
dist[WILDCHAR]sub[WILDCHAR]index-[WILDLINE].js [WILDLINE]
dist[WILDCHAR]index.html [WILDLINE]
dist[WILDCHAR]sub[WILDCHAR]index.html [WILDLINE]
[UNORDERED_END]

View file

@ -0,0 +1,2 @@
export const v = 1;
document.body.textContent = "Nested";

View file

@ -0,0 +1,3 @@
h1 {
color: red;
}

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Nested</title>
<script src="./index.ts" type="module"></script>
</head>
<body>
</body>
</html>

View file

@ -0,0 +1 @@
document.body.innerHTML = "Hello, world from sub!";

View file

@ -8,7 +8,7 @@ import {
unindent, unindent,
} from "./test_util.ts"; } from "./test_util.ts";
import { join, toFileUrl } from "@std/path"; import { basename, join, toFileUrl } from "@std/path";
class TempDir implements AsyncDisposable, Disposable { class TempDir implements AsyncDisposable, Disposable {
private path: string; private path: string;
@ -313,3 +313,55 @@ Deno.test("bundle: replaces require shim when platform is deno", async () => {
const output = await evalEsmString(js.text()); const output = await evalEsmString(js.text());
assertEquals(output.default, ["good", 1]); assertEquals(output.default, ["good", 1]);
}); });
Deno.test("bundle: html works", async () => {
using dir = new TempDir();
const entry = dir.join("index.html");
const input = unindent`
<html>
<body>
<script src="./index.ts"></script>
</body>
</html>
`;
const script = dir.join("index.ts");
const scriptInput = unindent`
console.log("hello");
document.body.innerHTML = "hello";
`;
const outDir = dir.join("dist");
await Deno.writeTextFile(entry, input);
await Deno.writeTextFile(script, scriptInput);
const result = await Deno.bundle({
entrypoints: [entry],
outputDir: outDir,
write: false,
});
const js = result.outputFiles!.find((f) =>
!!f.contents && f.path.endsWith(".js")
);
if (!js) {
throw new Error("No JS file found");
}
const html = result.outputFiles!.find((f) =>
!!f.contents && f.path.endsWith(".html")
)!;
if (!html) {
throw new Error("No HTML file found");
}
assert(result.success);
assertEquals(result.errors.length, 0);
assertEquals(result.outputFiles!.length, 2);
const jsFileName = basename(js.path);
assertStringIncludes(html.text(), `src="./${jsFileName}`);
assertStringIncludes(js.text(), "innerHTML");
assertStringIncludes(js.text(), "hello");
});